File size: 5,021 Bytes
7880373 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 | """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())
|