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())