"""Generate downloadable PDF and HTML analysis reports.""" from __future__ import annotations import base64 import os from datetime import datetime from fpdf import FPDF from data_processor import TEMP_DIR PDF_PATH = os.path.join(TEMP_DIR, "analysis_report.pdf") HTML_PATH = os.path.join(TEMP_DIR, "analysis_report.html") def _sanitize(text: str, max_word: int = 40) -> str: """fpdf core fonts are latin-1 only; also break words too wide for one line. fpdf2 raises 'Not enough horizontal space to render a single character' when an unbroken token (e.g. a long one-hot column name) exceeds the remaining cell width, so long words are split into chunks. """ replacements = {"²": "^2", "—": "-", "–": "-", "'": "'", "'": "'", """: '"', """: '"', "×": "x"} for a, b in replacements.items(): text = text.replace(a, b) text = text.encode("latin-1", "replace").decode("latin-1") words = [] for word in text.split(" "): while len(word) > max_word: words.append(word[:max_word]) word = word[max_word:] words.append(word) return " ".join(words) def generate_pdf_report(profile: dict, steps: list[str], result: dict) -> str: pdf = FPDF() pdf.set_auto_page_break(auto=True, margin=15) pdf.add_page() pdf.set_font("Helvetica", "B", 18) pdf.cell(0, 12, "ML Data Analysis Report", new_x="LMARGIN", new_y="NEXT") pdf.set_font("Helvetica", "", 10) pdf.set_text_color(120, 120, 120) pdf.cell(0, 6, f"Generated {datetime.now():%Y-%m-%d %H:%M}", new_x="LMARGIN", new_y="NEXT") pdf.set_text_color(0, 0, 0) pdf.ln(4) def section(title): pdf.set_font("Helvetica", "B", 13) pdf.set_fill_color(230, 236, 245) pdf.cell(0, 9, _sanitize(title), new_x="LMARGIN", new_y="NEXT", fill=True) pdf.ln(2) pdf.set_font("Helvetica", "", 10) def kv(key, value): pdf.set_font("Helvetica", "B", 10) pdf.cell(55, 6, _sanitize(str(key))) pdf.set_font("Helvetica", "", 10) # new_x must reset to the margin: fpdf2's multi_cell default leaves the # cursor at the cell's right edge, so the next line would start off-page pdf.multi_cell(0, 6, _sanitize(str(value)), new_x="LMARGIN", new_y="NEXT") section("1. Dataset Overview") kv("Rows", f"{profile['n_rows']:,}") kv("Columns", profile["n_cols"]) kv("Numeric columns", ", ".join(profile["numeric_columns"]) or "none") kv("Categorical columns", ", ".join(profile["categorical_columns"]) or "none") kv("Missing values", f"{profile['missing_total']:,}") kv("Duplicate rows", f"{profile['duplicate_rows']:,}") pdf.ln(4) section("2. Preprocessing Steps") for i, step in enumerate(steps, 1): pdf.multi_cell(0, 6, _sanitize(f"{i}. {step}"), new_x="LMARGIN", new_y="NEXT") pdf.ln(4) section("3. Model & Results") kv("Model", result["model_name"]) kv("Task", result["task"]) kv("Target column", result["target"]) kv("Training samples", f"{result['n_train']:,}") kv("Test samples", f"{result['n_test']:,}") pdf.ln(2) for metric, value in result["metrics"].items(): kv(metric, value) pdf.ln(4) for title, path in [ ("4. Result Plot", result.get("plot_path")), ("5. Feature Importance", result.get("importance_path")), ]: if path and os.path.exists(path): section(title) pdf.image(path, w=150) pdf.ln(4) pdf.output(PDF_PATH) return PDF_PATH def _img_b64(path): if not path or not os.path.exists(path): return None with open(path, "rb") as f: return base64.b64encode(f.read()).decode() def generate_html_report(profile: dict, steps: list[str], result: dict) -> str: plot_b64 = _img_b64(result.get("plot_path")) imp_b64 = _img_b64(result.get("importance_path")) metrics_rows = "".join( f"{k}{v}" for k, v in result["metrics"].items() ) steps_html = "".join(f"
  • {s}
  • " for s in steps) missing = {k: v for k, v in profile["missing_counts"].items() if v > 0} missing_html = ( ", ".join(f"{k} ({v})" for k, v in missing.items()) if missing else "None" ) html = f""" ML Data Analysis Report

    ML Data Analysis Report

    Generated {datetime.now():%Y-%m-%d %H:%M}

    1. Dataset Overview

    Rows{profile['n_rows']:,}
    Columns{profile['n_cols']}
    Numeric columns{', '.join(profile['numeric_columns']) or 'none'}
    Categorical columns{', '.join(profile['categorical_columns']) or 'none'}
    Missing values{profile['missing_total']:,} ({missing_html})
    Duplicate rows{profile['duplicate_rows']:,}

    2. Preprocessing Steps

      {steps_html}

    3. Model & Results

    {metrics_rows}
    Model{result['model_name']}
    Task{result['task']}
    Target{result['target']}
    Train / test samples{result['n_train']:,} / {result['n_test']:,}
    """ if plot_b64: html += f'

    4. Result Plot

    ' if imp_b64: html += f'

    5. Feature Importance

    ' html += "" with open(HTML_PATH, "w", encoding="utf-8") as f: f.write(html) return HTML_PATH