""" export.py - Traveler-friendly PDF brief. Compact 2-page layout: at-a-glance safety info, key risks, news, contacts. """ import os import tempfile from datetime import datetime from typing import Optional, Tuple from fpdf import FPDF from brief import ThreatBrief from map_utils import generate_country_map # --------------------------------------------------------------------------- # Colors # --------------------------------------------------------------------------- _BLACK = (20, 20, 20) _DARK = (10, 10, 10) _MID = (55, 55, 55) _MUTED = (110, 110, 110) _LIGHT_GREY = (246, 246, 246) _RULE = (210, 210, 210) _LINK = (30, 80, 200) _WHITE = (255, 255, 255) _SEV = { "Critical": (180, 20, 20), "High": (210, 70, 10), "Medium": (190, 100, 0), "Low": (20, 140, 60), "Minimal": (100, 110, 120), } _CON = { "High": (20, 140, 60), "Medium": (190, 100, 0), "Low": (180, 20, 20), } _ADV = { "1": ((20, 140, 60), "Level 1 - Normal Precautions"), "2": ((190, 100, 0), "Level 2 - Increased Caution"), "3": ((210, 70, 10), "Level 3 - Reconsider Travel"), "4": ((180, 20, 20), "Level 4 - Do Not Travel"), } _AIR = { "Open": (20, 140, 60), "Restricted": (210, 70, 10), "Partially Restricted": (190, 100, 0), "Closed": (180, 20, 20), "Unknown": (110, 110, 110), } # --------------------------------------------------------------------------- # Unicode safety # --------------------------------------------------------------------------- _SUBS = str.maketrans({ "—": "--", "–": "-", "‘": "'", "’": "'", "“": '"', "”": '"', "…": "...", "·": "*", "•": "-", " ": " ", "→": "->", "←": "<-", "\xe9": "e", "\xe8": "e", "\xea": "e", "\xe0": "a", "\xe2": "a", "\xf4": "o", "\xfb": "u", "\xfc": "u", "\xe4": "a", "\xf6": "o", "\xdf": "ss", "\xe7": "c", "\xf1": "n", "\xed": "i", "\xf3": "o", "\xfa": "u", "\xe1": "a", "\xe6": "ae", }) def _s(text) -> str: if not isinstance(text, str): text = str(text) return text.translate(_SUBS).encode("latin-1", errors="replace").decode("latin-1") # --------------------------------------------------------------------------- # PDF class # --------------------------------------------------------------------------- class _PDF(FPDF): def __init__(self, country="", date=""): super().__init__() self._country = country self._date = date def header(self): self.set_fill_color(255, 255, 255) self.rect(0, 0, self.w, self.h, "F") self.set_fill_color(*_LIGHT_GREY) self.rect(0, 0, self.w, 6, "F") self.set_font("Helvetica", "", 5.5) self.set_text_color(*_MUTED) self.set_xy(0, 0.8) self.cell(self.w, 4.5, "UNCLASSIFIED // FOR OFFICIAL USE ONLY // AI-GENERATED FROM OPEN SOURCES", align="C") self.ln(6) def footer(self): self.set_y(-14) self.set_fill_color(*_LIGHT_GREY) self.rect(0, self.h - 14, self.w, 14, "F") # Disclaimer self.set_font("Helvetica", "I", 5.5) self.set_text_color(*_MUTED) self.set_x(self.l_margin) self.cell(self.epw, 4, "AI-generated from open sources. Not for operational use without independent verification. " "Verify all embassy/consular contacts before travel.", align="L") self.ln(4) # Page line self.set_font("Helvetica", "", 6.5) self.set_x(self.l_margin) self.cell(self.epw / 2, 5, _s(f"{self._country} | {self._date} | OSINT Intelligence Platform"), align="L") self.cell(self.epw / 2, 5, f"Page {self.page_no()}", align="R") # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _rx(pdf): pdf.set_x(pdf.l_margin) def _lm(pdf): return pdf.l_margin def _epw(pdf): return pdf.epw def _rule(pdf, color=_RULE): _rx(pdf) pdf.set_draw_color(*color) pdf.line(_lm(pdf), pdf.get_y(), _lm(pdf) + _epw(pdf), pdf.get_y()) pdf.ln(2.5) def _section(pdf, title, accent=(50, 80, 160)): pdf.ln(3) _rx(pdf) y = pdf.get_y() pdf.set_fill_color(*_LIGHT_GREY) pdf.rect(_lm(pdf), y, _epw(pdf), 6.5, "F") pdf.set_fill_color(*accent) pdf.rect(_lm(pdf), y, 2.5, 6.5, "F") pdf.set_xy(_lm(pdf) + 5, y + 0.5) pdf.set_font("Helvetica", "B", 7.5) pdf.set_text_color(*_DARK) pdf.cell(_epw(pdf) - 5, 5.5, _s(title.upper()), ln=True) pdf.ln(2.5) def _label(pdf, text): _rx(pdf) pdf.set_font("Helvetica", "B", 6.5) pdf.set_text_color(*_MUTED) pdf.cell(_epw(pdf), 3.5, _s(text.upper()), ln=True) pdf.ln(0.5) def _body(pdf, text, size=8.5, color=None): _rx(pdf) pdf.set_font("Helvetica", "", size) pdf.set_text_color(*(color or _BLACK)) pdf.multi_cell(_epw(pdf), 4.5, _s(text)) def _bullets(pdf, items, size=8.5): if not items: return pdf.set_font("Helvetica", "", size) pdf.set_text_color(*_BLACK) for item in items: s = str(item).strip() if s and s.lower() not in ("n/a", "none", ""): _rx(pdf) pdf.multi_cell(_epw(pdf), 4.5, _s(f" • {s}")) def _badge(pdf, x, y, w, h, label, rgb): pdf.set_fill_color(*rgb) pdf.set_text_color(255, 255, 255) pdf.set_font("Helvetica", "B", 7.5) pdf.set_xy(x, y) pdf.cell(w, h, _s(f" {label}"), fill=True, border=0, ln=0) def _kv(pdf, label, value, label_w=28): if not value: return _rx(pdf) pdf.set_font("Helvetica", "B", 8) pdf.set_text_color(*_MID) pdf.cell(label_w, 4.5, _s(f"{label}:"), ln=False) pdf.set_font("Helvetica", "", 8) pdf.set_text_color(*_BLACK) pdf.multi_cell(_epw(pdf) - label_w, 4.5, _s(value)) # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- def generate_pdf(brief: ThreatBrief) -> str: date_str = brief.assessment_date or datetime.utcnow().strftime("%Y-%m-%d") pdf = _PDF(country=brief.country or "", date=date_str) pdf.set_auto_page_break(auto=True, margin=12) pdf.add_page() lm = pdf.l_margin epw = pdf.epw # ========================================================= # TITLE BLOCK # ========================================================= pdf.set_font("Helvetica", "B", 20) pdf.set_text_color(*_DARK) _rx(pdf) pdf.cell(epw, 9, _s(brief.country or "Unknown Country"), ln=True) pdf.set_font("Helvetica", "", 7.5) pdf.set_text_color(*_MUTED) _rx(pdf) pdf.cell(epw, 4, _s( f"{brief.region or 'Unknown Region'} | " f"Assessment: {date_str} | " f"Generated: {datetime.utcnow().strftime('%Y-%m-%d %H:%M UTC')}" ), ln=True) pdf.ln(3) # 4 status badges level = (brief.travel_advisory_level or "").strip() adv_rgb, adv_label = _ADV.get(level, ((110,110,110), f"Level {level}" if level else "Advisory N/A")) sev_rgb = _SEV.get(brief.severity, (110,110,110)) as_rgb = _AIR.get(brief.airspace_status, (110,110,110)) gap = 2 badge_w = (epw - gap * 3) / 4 by = pdf.get_y() pdf.set_font("Helvetica", "B", 7.5) _badge(pdf, lm, by, badge_w, 7, f"Severity: {brief.severity or 'N/A'}", sev_rgb) _badge(pdf, lm + (badge_w+gap), by, badge_w, 7, f"Confidence: {brief.confidence or 'N/A'}", _CON.get(brief.confidence, (110,110,110))) _badge(pdf, lm + (badge_w+gap)*2, by, badge_w, 7, f"Advisory: L{level or '?'}", adv_rgb) _badge(pdf, lm + (badge_w+gap)*3, by, badge_w, 7, f"Airspace: {brief.airspace_status or 'N/A'}", as_rgb) pdf.ln(9) pdf.ln(3) _rule(pdf) # ========================================================= # MAP + ADVISORY SUMMARY (side by side) # ========================================================= map_col_w = epw * 0.52 info_col_w = epw - map_col_w - 4 row_start = pdf.get_y() # --- Map (left) --- map_path = map_err = None try: result = generate_country_map(brief.country or "") map_path, map_err = result if isinstance(result, tuple) else (result, None) except Exception as e: map_err = str(e) map_h = 0 if map_path: try: pdf.image(map_path, x=lm, y=row_start, w=map_col_w) map_h = map_col_w * (540 / 900) # native aspect ratio except Exception as e: map_err = str(e) finally: try: os.unlink(map_path) except: pass if not map_path or map_err: pdf.set_xy(lm, row_start) pdf.set_fill_color(*_LIGHT_GREY) pdf.rect(lm, row_start, map_col_w, 38, "F") pdf.set_xy(lm, row_start + 15) pdf.set_font("Helvetica", "I", 8) pdf.set_text_color(*_MUTED) pdf.cell(map_col_w, 5, _s(f"Map unavailable"), align="C", ln=True) map_h = 38 # --- Advisory info (right) --- info_x = lm + map_col_w + 4 pdf.set_xy(info_x, row_start) # Advisory banner pdf.set_fill_color(*adv_rgb) pdf.set_text_color(255,255,255) pdf.set_font("Helvetica", "B", 8) pdf.cell(info_col_w, 7, _s(f" {adv_label}"), fill=True, ln=True) pdf.ln(2) if brief.travel_advisory_indicators: pdf.set_x(info_x) pdf.set_font("Helvetica", "B", 7) pdf.set_text_color(*_MUTED) pdf.cell(info_col_w, 3.5, "RISK CATEGORIES", ln=True) pdf.set_x(info_x) pdf.set_font("Helvetica", "", 8) pdf.set_text_color(*_BLACK) pdf.multi_cell(info_col_w, 4.5, _s(", ".join(brief.travel_advisory_indicators))) pdf.ln(2) if brief.narrative_summary: pdf.set_x(info_x) pdf.set_font("Helvetica", "B", 7) pdf.set_text_color(*_MUTED) pdf.cell(info_col_w, 3.5, "SITUATION", ln=True) pdf.set_x(info_x) pdf.set_font("Helvetica", "", 8) pdf.set_text_color(*_BLACK) # Limit to ~3 lines in this column summary = brief.narrative_summary[:260] if len(brief.narrative_summary) > 260: summary = summary.rsplit(" ", 1)[0] + "..." pdf.multi_cell(info_col_w, 4.5, _s(summary)) # Advance Y past the taller of the two columns pdf.set_y(row_start + max(map_h, pdf.get_y() - row_start) + 3) _rule(pdf) # ========================================================= # KEY RISKS (2 columns: findings | watch items) # ========================================================= _section(pdf, "Key Risks & Watch Items", accent=(180, 20, 20)) col_w = epw / 2 - 2 row_y = pdf.get_y() row_pg = pdf.page # Left: risk analysis snippet + key findings _rx(pdf) if brief.risk_analysis: pdf.set_font("Helvetica", "", 8.5) pdf.set_text_color(*_BLACK) ra = brief.risk_analysis[:300] if len(brief.risk_analysis) > 300: ra = ra.rsplit(" ", 1)[0] + "..." pdf.multi_cell(col_w, 4.5, _s(ra)) pdf.ln(1.5) all_findings = (brief.key_findings or []) + (brief.indicators_of_escalation or []) if all_findings: _rx(pdf) pdf.set_font("Helvetica", "B", 6.5) pdf.set_text_color(*_MUTED) pdf.cell(col_w, 3.5, "KEY FINDINGS", ln=True) pdf.set_font("Helvetica", "", 8.5) pdf.set_text_color(*_BLACK) for item in all_findings[:5]: s = str(item).strip() if s and s.lower() not in ("n/a","none",""): _rx(pdf) pdf.multi_cell(col_w, 4.5, _s(f" - {s}")) left_end_y = pdf.get_y() left_end_pg = pdf.page # Right: actors + watch items if left_end_pg == row_pg: rx = lm + col_w + 4 pdf.set_xy(rx, row_y) all_actors = (brief.primary_actors or [])[:4] if all_actors: pdf.set_font("Helvetica", "B", 6.5) pdf.set_text_color(*_MUTED) pdf.cell(col_w, 3.5, "PRIMARY ACTORS", ln=True) pdf.set_font("Helvetica", "", 8.5) pdf.set_text_color(*_BLACK) for a in all_actors: pdf.set_x(rx) pdf.multi_cell(col_w, 4.5, _s(f" - {a}")) pdf.set_x(rx); pdf.ln(2) watch = (brief.recommended_watch_items or [])[:4] if watch: pdf.set_x(rx) pdf.set_font("Helvetica", "B", 6.5) pdf.set_text_color(*_MUTED) pdf.cell(col_w, 3.5, "MONITOR", ln=True) pdf.set_font("Helvetica", "", 8.5) pdf.set_text_color(*_BLACK) for w in watch: pdf.set_x(rx) pdf.multi_cell(col_w, 4.5, _s(f" - {w}")) pdf.set_y(max(left_end_y, pdf.get_y()) + 2) else: pdf.set_y(left_end_y + 2) _rule(pdf) # ========================================================= # AIRSPACE + TRAVEL CONTACTS (side by side) # ========================================================= _section(pdf, "Airspace & Travel", accent=(210, 70, 10)) col_w = epw / 2 - 2 row_y = pdf.get_y() row_pg = pdf.page # Left: airspace _rx(pdf) pdf.set_fill_color(*as_rgb) pdf.set_text_color(255,255,255) pdf.set_font("Helvetica", "B", 8) pdf.cell(col_w, 6.5, _s(f" {brief.airspace_status or 'Unknown'}"), fill=True, ln=True) pdf.ln(1.5) if brief.aviation_notes: _rx(pdf) pdf.set_font("Helvetica", "", 8) pdf.set_text_color(*_MID) note = brief.aviation_notes[:200] pdf.multi_cell(col_w, 4.5, _s(note)) pdf.ln(1) combined_air = (brief.no_fly_zones or []) + (brief.airspace_restrictions or []) if combined_air: _rx(pdf) pdf.set_font("Helvetica", "B", 6.5) pdf.set_text_color(*_MUTED) pdf.cell(col_w, 3.5, "RESTRICTIONS / NO-FLY ZONES", ln=True) pdf.set_font("Helvetica", "", 8) pdf.set_text_color(*_BLACK) for item in combined_air[:4]: if str(item).strip().lower() not in ("none identified", "n/a", "none", ""): _rx(pdf) pdf.multi_cell(col_w, 4.5, _s(f" - {item}")) air_end_y = pdf.get_y() air_end_pg = pdf.page # Right: travel advisory + embassy if air_end_pg == row_pg: rx = lm + col_w + 4 pdf.set_xy(rx, row_y) pdf.set_fill_color(*adv_rgb) pdf.set_text_color(255,255,255) pdf.set_font("Helvetica", "B", 8) pdf.cell(col_w, 6.5, _s(f" {adv_label}"), fill=True, ln=True) pdf.ln(1.5) if brief.travel_advisory_date: pdf.set_x(rx) pdf.set_font("Helvetica", "I", 7.5) pdf.set_text_color(*_MUTED) pdf.cell(col_w, 4, _s(f"Updated: {brief.travel_advisory_date}"), ln=True) if brief.passport_country and brief.embassy_name: pdf.set_x(rx); pdf.ln(1.5) pdf.set_font("Helvetica", "B", 7.5) pdf.set_text_color(*_DARK) pdf.set_x(rx) pdf.cell(col_w, 4.5, _s(f"{brief.passport_country} Embassy"), ln=True) pdf.set_x(rx) pdf.set_font("Helvetica", "", 8) pdf.set_text_color(*_BLACK) pdf.multi_cell(col_w, 4.5, _s(brief.embassy_name)) if brief.embassy_address: pdf.set_x(rx) pdf.multi_cell(col_w, 4.5, _s(brief.embassy_address)) if brief.embassy_emergency_phone: pdf.set_x(rx) pdf.set_font("Helvetica", "B", 8) pdf.set_text_color((180,20,20)) pdf.cell(col_w, 4.5, _s(f"Emergency: {brief.embassy_emergency_phone}"), ln=True) if brief.embassy_phone: pdf.set_x(rx) pdf.set_font("Helvetica", "", 8) pdf.set_text_color(*_BLACK) pdf.cell(col_w, 4.5, _s(f"Main: {brief.embassy_phone}"), ln=True) if brief.embassy_website: pdf.set_x(rx) pdf.set_font("Helvetica", "I", 7.5) pdf.set_text_color(*_LINK) pdf.cell(col_w, 4.5, _s(brief.embassy_website), ln=True) pdf.set_y(max(air_end_y, pdf.get_y()) + 2) else: pdf.set_y(air_end_y + 2) _rule(pdf) # ========================================================= # NEWS HEADLINES (compact — title + source + date only) # ========================================================= news = brief.notable_news or [] if news: _section(pdf, f"Latest News ({len(news)} articles)", accent=(37, 99, 200)) for article in news: # Title bold _rx(pdf) pdf.set_font("Helvetica", "B", 8.5) pdf.set_text_color(*_BLACK) pdf.multi_cell(epw, 4.5, _s(article.title or "Untitled")) # Source | date | [NOTABLE] on same line _rx(pdf) pdf.set_font("Helvetica", "I", 7) pdf.set_text_color(*_MUTED) date_s = (article.published or "")[:16] notable = " [NOTABLE]" if article.notable else "" pdf.cell(epw, 3.5, _s(f"{article.source} | {date_s}{notable}"), ln=True) # One-line summary (capped at 140 chars) if article.summary: _rx(pdf) pdf.set_font("Helvetica", "", 8) pdf.set_text_color(*_MID) summ = article.summary[:140] if len(article.summary) > 140: summ = summ.rsplit(" ", 1)[0] + "..." pdf.multi_cell(epw, 4, _s(summ)) # URL if article.url: _rx(pdf) pdf.set_font("Helvetica", "I", 7) pdf.set_text_color(*_LINK) url_d = article.url[:90] + "..." if len(article.url) > 90 else article.url pdf.cell(epw, 3.5, _s(url_d), ln=True) pdf.ln(1.5) _rx(pdf) pdf.set_draw_color(*_RULE) pdf.line(lm, pdf.get_y(), lm + epw, pdf.get_y()) pdf.ln(2) path = tempfile.mktemp(suffix=".pdf") pdf.output(path) return path