"""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) # new_x/new_y must be explicit: fpdf2's multi_cell defaults leave the # cursor at the right margin (not the left) after short, unwrapped text, # which then starves the *next* multi_cell call of horizontal space. 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() # ── Header ──────────────────────────────────────────────────────────── company = brief.get("company_name") or "" # Helvetica's core (non-Unicode) font can't render an em dash — use a # plain hyphen so the header never falls through to a "?" glyph. 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) # ── What matters most ───────────────────────────────────────────────── if brief.get("what_matters_most"): _section_title(pdf, "What matters most") _body_text(pdf, brief["what_matters_most"]) # ── Bull / bear ─────────────────────────────────────────────────────── 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) # ── What to watch ───────────────────────────────────────────────────── 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())