| """export/one_pager.py β one-page PDF export of a research brief. |
| |
| Pure PDF generation (fpdf2, no headless browser). Deliberately sober: |
| black/gray text on white, a single emerald accent for section headers β |
| matches the app's brand palette without trying to replicate the full HTML |
| layout. Sections are stacked (not columnar) to keep cursor placement simple |
| and predictable across fpdf2 versions. |
| """ |
| from __future__ import annotations |
|
|
| from fpdf import FPDF |
|
|
| _EMERALD = (16, 185, 129) |
| _TEXT = (10, 10, 10) |
| _MUTED = (107, 114, 128) |
|
|
| _DISCLAIMER = ( |
| "Sources: SEC EDGAR filings, earnings call transcripts, structured financial metrics. " |
| "Not investment advice. No price targets or buy/sell recommendations." |
| ) |
|
|
|
|
| def _safe_text(value: object) -> str: |
| """fpdf2's core Helvetica font is Latin-1 only; drop unsupported glyphs |
| (curly quotes, em dashes, etc. common in LLM output) rather than raising.""" |
| return str(value or "").encode("latin-1", "replace").decode("latin-1") |
|
|
|
|
| class _OnePager(FPDF): |
| def footer(self) -> None: |
| self.set_y(-14) |
| self.set_font("Helvetica", "I", 6.5) |
| self.set_text_color(*_MUTED) |
| self.multi_cell(0, 3.4, _safe_text(_DISCLAIMER), align="C", new_x="LMARGIN", new_y="NEXT") |
|
|
|
|
| def _section_title(pdf: _OnePager, title: str) -> None: |
| pdf.set_font("Helvetica", "B", 10) |
| pdf.set_text_color(*_EMERALD) |
| pdf.cell(0, 6, _safe_text(title.upper()), new_x="LMARGIN", new_y="NEXT") |
| pdf.set_text_color(*_TEXT) |
|
|
|
|
| def _body_text(pdf: _OnePager, text: str, size: float = 9) -> None: |
| pdf.set_font("Helvetica", "", size) |
| pdf.set_text_color(*_TEXT) |
| |
| |
| |
| pdf.multi_cell(0, 4.6, _safe_text(text), new_x="LMARGIN", new_y="NEXT") |
| pdf.ln(1.5) |
|
|
|
|
| def _bullet_list(pdf: _OnePager, items: list, limit: int = 4) -> None: |
| pdf.set_font("Helvetica", "", 8.5) |
| pdf.set_text_color(*_TEXT) |
| for item in items[:limit]: |
| text = item.get("text") if isinstance(item, dict) else str(item) |
| if not text: |
| continue |
| pdf.multi_cell(0, 4.4, _safe_text(f"- {text}"), new_x="LMARGIN", new_y="NEXT") |
| pdf.ln(2) |
|
|
|
|
| def build_pdf(brief: dict, ticker: str) -> bytes: |
| """Render *brief* as a one-page A4 PDF. Returns raw PDF bytes. |
| |
| Content is deliberately bounded (3-4 bullets per section) to fit one |
| page; auto page-break is disabled so overflow is clipped rather than |
| silently spilling onto an unexpected second page. |
| """ |
| pdf = _OnePager(format="A4") |
| pdf.set_margins(15, 15, 15) |
| pdf.set_auto_page_break(auto=False) |
| pdf.add_page() |
|
|
| |
| company = brief.get("company_name") or "" |
| |
| |
| header_text = f"{ticker} - {company}" if company else ticker |
| pdf.set_font("Helvetica", "B", 18) |
| pdf.set_text_color(*_TEXT) |
| pdf.cell(0, 9, _safe_text(header_text), new_x="LMARGIN", new_y="NEXT") |
|
|
| filing_date = brief.get("filing_date", "") |
| generated_at = str(brief.get("generated_at") or "")[:10] |
| pdf.set_font("Helvetica", "", 8.5) |
| pdf.set_text_color(*_MUTED) |
| pdf.cell( |
| 0, 5, |
| _safe_text(f"Filing: {filing_date or 'β'} | Generated: {generated_at or 'β'}"), |
| new_x="LMARGIN", new_y="NEXT", |
| ) |
| pdf.ln(3) |
|
|
| |
| if brief.get("what_matters_most"): |
| _section_title(pdf, "What matters most") |
| _body_text(pdf, brief["what_matters_most"]) |
|
|
| |
| if brief.get("bull_points"): |
| _section_title(pdf, "Bull points") |
| _bullet_list(pdf, brief["bull_points"], limit=3) |
|
|
| if brief.get("bear_points"): |
| _section_title(pdf, "Bear points") |
| _bullet_list(pdf, brief["bear_points"], limit=3) |
|
|
| |
| watch = brief.get("what_to_watch") or [] |
| if watch: |
| _section_title(pdf, "What to watch") |
| watch_texts = [w if isinstance(w, str) else (w.get("text") or "") for w in watch] |
| _bullet_list(pdf, [w for w in watch_texts if w], limit=4) |
|
|
| return bytes(pdf.output()) |
|
|