"""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
| Model | {result['model_name']} |
| Task | {result['task']} |
| Target | {result['target']} |
| Train / test samples | {result['n_train']:,} / {result['n_test']:,} |
{metrics_rows}
"""
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