| """ |
| 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 |
|
|
| |
| |
| |
| _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), |
| } |
|
|
| |
| |
| |
| _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") |
|
|
|
|
| |
| |
| |
| 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") |
| |
| 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) |
| |
| 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") |
|
|
|
|
| |
| |
| |
| 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)) |
|
|
|
|
| |
| |
| |
| 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 |
|
|
| |
| |
| |
| 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) |
|
|
| |
| 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_col_w = epw * 0.52 |
| info_col_w = epw - map_col_w - 4 |
| row_start = pdf.get_y() |
|
|
| |
| 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) |
| 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 |
|
|
| |
| info_x = lm + map_col_w + 4 |
| pdf.set_xy(info_x, row_start) |
|
|
| |
| 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) |
| |
| 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)) |
|
|
| |
| pdf.set_y(row_start + max(map_h, pdf.get_y() - row_start) + 3) |
| _rule(pdf) |
|
|
| |
| |
| |
| _section(pdf, "Key Risks & Watch Items", accent=(180, 20, 20)) |
|
|
| col_w = epw / 2 - 2 |
| row_y = pdf.get_y() |
| row_pg = pdf.page |
|
|
| |
| _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 |
|
|
| |
| 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) |
|
|
| |
| |
| |
| _section(pdf, "Airspace & Travel", accent=(210, 70, 10)) |
|
|
| col_w = epw / 2 - 2 |
| row_y = pdf.get_y() |
| row_pg = pdf.page |
|
|
| |
| _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 |
|
|
| |
| 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 = brief.notable_news or [] |
| if news: |
| _section(pdf, f"Latest News ({len(news)} articles)", accent=(37, 99, 200)) |
|
|
| for article in news: |
| |
| _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")) |
|
|
| |
| _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) |
|
|
| |
| 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)) |
|
|
| |
| 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 |
|
|