""" ResearchPilot AI — PDF Generator (ReportLab edition) Why ReportLab instead of fpdf2: fpdf2 requires manually tracking the cursor (x/y) for every element, which is what caused the two-column drift bug in the old version. ReportLab's SimpleDocTemplate uses a "flowable" model — paragraphs, spacers, and images are appended to a list and the library handles all positioning, wrapping, and page-breaking automatically. This makes safe chart embedding trivial. Academic paper style: - Deep blue (#1e3a5f) bold headings with a horizontal rule underneath, matching the "Real-World Examples" reference style. - Single-column flowable layout, 0.75" margins. - Charts (Plotly → PNG via kaleido) inserted directly under their matching section heading, with calculated spacing before/after so text never collides with the image. """ import io import re import json from datetime import date from reportlab.lib.pagesizes import A4 from reportlab.lib.units import inch from reportlab.lib import colors from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_JUSTIFY from reportlab.platypus import ( SimpleDocTemplate, Paragraph, Spacer, Image, HRFlowable, PageBreak, KeepTogether, Table, TableStyle ) from reportlab.pdfgen import canvas as pdfcanvas # ── Brand palette ──────────────────────────────────────────────── DEEP_BLUE = colors.HexColor("#1e3a5f") # academic heading colour ACCENT = colors.HexColor("#6366f1") # indigo accent (rules, cover) TEXT_DARK = colors.HexColor("#1e1e2e") MUTED = colors.HexColor("#64748b") COVER_BG = colors.HexColor("#0f172a") COVER_TEXT = colors.HexColor("#f1f5f9") MARGIN = 0.75 * inch def clean(text: str) -> str: """Strip markdown syntax. Escape XML-unsafe chars for ReportLab paragraphs.""" text = re.sub(r"^#{1,6}\s+", "", text) text = re.sub(r"\*\*(.+?)\*\*", r"\1", text) # bold markers text = re.sub(r"\*(.+?)\*", r"\1", text) # italic markers text = re.sub(r"`(.+?)`", r"\1", text) text = text.replace("&", "&").replace("<", "<").replace(">", ">") return text def build_styles(): ss = getSampleStyleSheet() ss.add(ParagraphStyle( name="RPTitle", fontName="Helvetica-Bold", fontSize=15, textColor=DEEP_BLUE, spaceBefore=14, spaceAfter=2, leading=19, )) ss.add(ParagraphStyle( name="RPHeading2", fontName="Helvetica-Bold", fontSize=12.5, textColor=DEEP_BLUE, spaceBefore=16, spaceAfter=2, leading=16, )) ss.add(ParagraphStyle( name="RPHeading3", fontName="Helvetica-Bold", fontSize=11, textColor=colors.HexColor("#334155"), spaceBefore=10, spaceAfter=4, leading=14, )) ss.add(ParagraphStyle( name="RPBody", fontName="Helvetica", fontSize=10.3, textColor=TEXT_DARK, leading=15.5, spaceAfter=6, alignment=TA_JUSTIFY, )) ss.add(ParagraphStyle( name="RPBullet", fontName="Helvetica", fontSize=10.3, textColor=TEXT_DARK, leading=15, spaceAfter=4, leftIndent=14, bulletIndent=2, )) ss.add(ParagraphStyle( name="RPCaption", fontName="Helvetica-Oblique", fontSize=8.5, textColor=MUTED, alignment=TA_CENTER, spaceBefore=2, spaceAfter=12, )) ss.add(ParagraphStyle( name="RPCoverBrand", fontName="Helvetica-Bold", fontSize=10, textColor=colors.HexColor("#a5b4fc"), alignment=TA_CENTER, leading=14, )) ss.add(ParagraphStyle( name="RPCoverTitle", fontName="Helvetica-Bold", fontSize=22, textColor=COVER_TEXT, alignment=TA_CENTER, leading=28, )) ss.add(ParagraphStyle( name="RPCoverMeta", fontName="Helvetica", fontSize=10, textColor=colors.HexColor("#94a3b8"), alignment=TA_CENTER, leading=15, )) ss.add(ParagraphStyle( name="RPReference", fontName="Helvetica", fontSize=9.3, textColor=TEXT_DARK, leading=13.5, spaceAfter=8, leftIndent=14, )) return ss def academic_heading(text: str, style, rule_color=DEEP_BLUE, rule_width=1.1): """ Builds a bold deep-blue heading + horizontal rule underneath it, as a KeepTogether block so the heading never gets orphaned at the bottom of a page separated from its underline. """ flow = [ Paragraph(text, style), Spacer(1, 3), HRFlowable(width="100%", thickness=rule_width, color=rule_color, spaceBefore=0, spaceAfter=10), ] return KeepTogether(flow) def chart_image_flowable(png_bytes: bytes, max_width_in: float = 6.4): """ Converts chart PNG bytes into a properly-sized ReportLab Image flowable, preserving aspect ratio, with calculated spacers before/after so the image never collides with surrounding text. """ from PIL import Image as PILImage pil_img = PILImage.open(io.BytesIO(png_bytes)) w_px, h_px = pil_img.size aspect = h_px / w_px target_w = max_width_in * inch target_h = target_w * aspect # Cap height so a single chart never exceeds ~40% of page height max_h = 3.6 * inch if target_h > max_h: target_h = max_h target_w = target_h / aspect img = Image(io.BytesIO(png_bytes), width=target_w, height=target_h) return [Spacer(1, 6), img, Spacer(1, 10)] def render_charts_for_section(chart_specs, section_key, styles): """ Returns a list of flowables for any charts tagged with this section_key. section_key examples: 'statistics', 'key_findings' """ flowables = [] matched = [c for c in chart_specs if c.get("section") == section_key] if not matched: return flowables try: import plotly.graph_objects as go import plotly.io as pio except ImportError: flowables.append(Paragraph( "[Chart unavailable — plotly not installed]", styles["RPCaption"] )) return flowables for spec in matched: try: fig = go.Figure(json.loads(spec["json"])) fig.update_layout( plot_bgcolor="#ffffff", paper_bgcolor="#ffffff", font=dict(color="#1e293b", size=12), margin=dict(l=40, r=30, t=50, b=40), ) png_bytes = pio.to_image(fig, format="png", width=900, height=480, scale=2) flowables.extend(chart_image_flowable(png_bytes)) if spec.get("caption"): flowables.append(Paragraph(clean(spec["caption"]), styles["RPCaption"])) except Exception: flowables.append(Paragraph( "[Chart could not be rendered]", styles["RPCaption"] )) return flowables # ── Section-name → chart-tag mapping ─────────────────────────── # Report headings (## Statistical Overview, ## Key Findings, etc.) are # matched against this map (case-insensitive substring match) to decide # where each generated chart gets seamlessly inserted. SECTION_CHART_MAP = { "statistical overview": "statistics", "key findings": "key_findings", "data": "statistics", } def _section_key_for_heading(heading_text: str): h = heading_text.lower() for key_phrase, tag in SECTION_CHART_MAP.items(): if key_phrase in h: return tag return None def _strip_references_section(report: str) -> str: """ Removes a trailing '## References' (or '# References') section from the report markdown, including everything after it up to the next heading of equal-or-higher level or the end of the document / footer line. This prevents the LLM-written reference list from duplicating the structured citations list rendered separately by generate_pdf(). """ lines = report.split("\n") out = [] skipping = False skip_level = None heading_re = re.compile(r"^(#{1,6})\s+(.*)$") for line in lines: m = heading_re.match(line.strip()) if m: level = len(m.group(1)) title = m.group(2).strip().lower() if skipping: # Stop skipping once we hit a heading at the same or # shallower level than the References heading itself. if level <= skip_level: skipping = False else: continue # still inside the references subsection if title == "references": skipping = True skip_level = level continue # drop the heading line itself if skipping: # Also stop skipping on the footer divider/attribution line if line.strip().startswith("---") or "Report generated by" in line: skipping = False else: continue out.append(line) return "\n".join(out) def _cover_page(canvas_obj, doc, query, today, score, agents_str): canvas_obj.saveState() canvas_obj.setFillColor(COVER_BG) canvas_obj.rect(0, 0, doc.pagesize[0], doc.pagesize[1], fill=1, stroke=0) canvas_obj.setFillColor(ACCENT) canvas_obj.rect(0, 0, 5, doc.pagesize[1], fill=1, stroke=0) canvas_obj.restoreState() def generate_pdf(report, query, quality_score, citations, chart_json_list=None, thumbnail_svg=None, active_agents=None): """ Builds a professional, academic-style research PDF using ReportLab's flowable layout engine. Charts are safely embedded directly beneath their matching section heading with auto-calculated spacing. chart_json_list: list of Plotly JSON strings (order = generation order). Charts are auto-tagged to sections using simple heuristics: the first chart goes under "Statistical Overview" / "Key Findings" if present, remaining charts append to an "Additional Data Visualizations" section near the end — never overlapping text. """ active_agents = active_agents or [] chart_json_list = chart_json_list or [] today = date.today().strftime("%B %d, %Y") styles = build_styles() buffer = io.BytesIO() doc = SimpleDocTemplate( buffer, pagesize=A4, leftMargin=MARGIN, rightMargin=MARGIN, topMargin=MARGIN, bottomMargin=MARGIN, title=f"ResearchPilot AI — {query[:80]}", ) story = [] # ── COVER PAGE ─────────────────────────────────────────────── story.append(Spacer(1, 1.8 * inch)) story.append(Paragraph("RESEARCHPILOT AI  |  AUTONOMOUS MULTI-AGENT RESEARCH", styles["RPCoverBrand"])) story.append(Spacer(1, 0.5 * inch)) story.append(Paragraph(clean(query[:140]), styles["RPCoverTitle"])) story.append(Spacer(1, 1.6 * inch)) story.append(Paragraph(f"Generated: {today}", styles["RPCoverMeta"])) story.append(Paragraph(f"Quality Score: {quality_score:.1f} / 10", styles["RPCoverMeta"])) agents_str = ", ".join(active_agents) if active_agents else "—" story.append(Paragraph(f"Agents: {agents_str}", styles["RPCoverMeta"])) story.append(PageBreak()) # ── Build chart spec list with auto-section tagging ─────────── chart_specs = [] if chart_json_list: # First chart → Statistical Overview, second → Key Findings, # rest → end-of-report "Additional Visualizations" section. tags = ["statistics", "key_findings"] for idx, cj in enumerate(chart_json_list): tag = tags[idx] if idx < len(tags) else None chart_specs.append({ "json": cj, "section": tag, "caption": f"Figure {idx + 1}. Data visualization generated by the Statistics Agent.", }) used_chart_indices = set( i for i, spec in enumerate(chart_specs) if spec["section"] is not None ) # ── Strip any "## References" section from the report body ──── # The synthesizer LLM often writes its own References section as # plain text. We only want the structured `citations` list (passed # in separately) to render — otherwise references appear twice. report = _strip_references_section(report) # ── REPORT BODY — parse markdown, insert charts inline ──────── lines = report.split("\n") i = 0 pending_bullets = [] def flush_bullets(): nonlocal pending_bullets if pending_bullets: for b in pending_bullets: story.append(Paragraph(f"•  {clean(b)}", styles["RPBullet"])) pending_bullets = [] while i < len(lines): raw = lines[i] line = raw.rstrip() i += 1 if not line.strip(): flush_bullets() continue if line.strip() == "---": flush_bullets() continue if line.startswith("# "): flush_bullets() story.append(Paragraph(clean(line[2:].strip()), styles["RPTitle"])) story.append(Spacer(1, 4)) elif line.startswith("## "): flush_bullets() heading_text = line[3:].strip() story.append(academic_heading(clean(heading_text), styles["RPHeading2"])) # Seamless chart insertion directly under matching heading section_tag = _section_key_for_heading(heading_text) if section_tag: chart_flowables = render_charts_for_section(chart_specs, section_tag, styles) story.extend(chart_flowables) elif line.startswith("### "): flush_bullets() story.append(Paragraph(clean(line[4:].strip()), styles["RPHeading3"])) elif line.startswith("• ") or line.startswith("- "): pending_bullets.append(line[2:].strip()) else: flush_bullets() story.append(Paragraph(clean(line), styles["RPBody"])) flush_bullets() # ── Any charts not matched to a section → appendix ──────────── leftover = [s for idx, s in enumerate(chart_specs) if idx not in used_chart_indices] if leftover: story.append(PageBreak()) story.append(academic_heading("Additional Data Visualizations", styles["RPHeading2"])) for spec in leftover: try: import plotly.graph_objects as go, plotly.io as pio fig = go.Figure(json.loads(spec["json"])) fig.update_layout(plot_bgcolor="#ffffff", paper_bgcolor="#ffffff", font=dict(color="#1e293b", size=12), margin=dict(l=40, r=30, t=50, b=40)) png_bytes = pio.to_image(fig, format="png", width=900, height=480, scale=2) story.extend(chart_image_flowable(png_bytes)) story.append(Paragraph(clean(spec["caption"]), styles["RPCaption"])) except Exception: pass # ── REFERENCES PAGE ──────────────────────────────────────────── if citations: story.append(PageBreak()) story.append(academic_heading("References", styles["RPHeading2"])) for c in citations: apa = clean(c.get("apa") or f"[{c.get('number','')}] {c.get('title','')}") story.append(Paragraph(apa, styles["RPReference"])) # ── Build with cover-page background callback ────────────────── def on_first_page(canvas_obj, doc_obj): _cover_page(canvas_obj, doc_obj, query, today, quality_score, agents_str) def on_later_pages(canvas_obj, doc_obj): canvas_obj.saveState() canvas_obj.setFont("Helvetica", 8) canvas_obj.setFillColor(MUTED) canvas_obj.drawCentredString( doc_obj.pagesize[0] / 2, 0.4 * inch, f"ResearchPilot AI — Page {doc_obj.page}" ) canvas_obj.restoreState() doc.build(story, onFirstPage=on_first_page, onLaterPages=on_later_pages) pdf_bytes = buffer.getvalue() buffer.close() return pdf_bytes