"""Report generation — Markdown, JSON, HTML, PDF. Produces self-contained reports from project analysis data. Markdown and JSON are authoritative formats per ADR-008. HTML and PDF are optional renderings derived from the canonical formats. Every report includes: - Methodology section: profile, rules_version, backend, adapter, parameters - Full provenance: cli_version, project_id, binary_id, binary_sha256, analysis_id (UUID), generated_at Report types: - triage: Structured triage analysis output (observations, heuristics, unknowns) - focused: Analysis focused on a specific entity (requires selector) - project: Full project analysis state summary """ from __future__ import annotations import json import os from datetime import datetime, timezone from typing import Any from uuid import UUID, uuid4 from binary_analysis import __version__ as _cli_version from binary_analysis.domain.enums import ReportType # --------------------------------------------------------------------------- # Methodology builder # --------------------------------------------------------------------------- def build_methodology( profile: str = "standard", rules_version: str = "1.0.0", backend: str = "none", adapter: str = "none", parameters: dict[str, Any] | None = None, ) -> dict[str, Any]: """Build the methodology section for a report. All fields must be non-null per the validation contract. Args: profile: Analysis profile used (e.g., "standard", "quick", "deep"). rules_version: Version of the rules engine used. backend: Backend name (e.g., "Ghidra", "none"). adapter: Adapter name (e.g., "ghidra", "fake"). parameters: Any parameter overrides from defaults. Returns: Methodology dict with profile, rules_version, backend, adapter, parameters. """ return { "profile": profile, "rules_version": rules_version, "backend": backend, "adapter": adapter, "parameters": parameters if parameters is not None else {}, } # --------------------------------------------------------------------------- # Provenance builder # --------------------------------------------------------------------------- def build_provenance( *, cli_version: str | None = None, project_id: str | None = None, binary_id: str | None = None, binary_sha256: str | None = None, analysis_id: UUID | None = None, generated_at: str | None = None, ) -> dict[str, Any]: """Build the provenance section for a report. Every report must include cli_version, project_id, binary_id, binary_sha256, analysis_id (UUID), and generated_at (ISO 8601). Two sequential reports on the same binary have different analysis_id values. Args: cli_version: CLI version string. project_id: Project UUID. binary_id: Binary UUID. binary_sha256: Binary SHA-256 hash (64 hex chars). analysis_id: Unique UUID for this analysis run (auto-generated if None). generated_at: ISO 8601 timestamp (auto-generated if None). Returns: Provenance dict with all required fields. """ if analysis_id is None: analysis_id = uuid4() if generated_at is None: generated_at = datetime.now(timezone.utc).isoformat() return { "cli_version": cli_version or _cli_version, "project_id": project_id, "binary_id": binary_id, "binary_sha256": binary_sha256, "analysis_id": str(analysis_id), "generated_at": generated_at, } # --------------------------------------------------------------------------- # Report data collection # --------------------------------------------------------------------------- def collect_triage_data( manifest: dict[str, Any], adapter: Any, binary: Any, profile_name: str, ) -> dict[str, Any]: """Collect triage data from the analysis for a triage report. Args: manifest: Project manifest dict. adapter: Backend adapter instance. binary: Binary domain entity. profile_name: Analysis profile name. Returns: Dict with observations, heuristics, unknowns from triage analysis. """ try: triage_result = adapter.run_triage(binary) except Exception: return { "observations": [], "heuristics": [], "unknowns": [], "partial": True, "error": "Triage analysis could not be completed.", } observations_data: list[dict[str, Any]] = [] for obs in triage_result.observations: obs_dict: dict[str, Any] = { "category": obs.category, "description": obs.description, "source": obs.source, } if obs.address is not None: obs_dict["address"] = obs.address.to_dict() if obs.evidence is not None: obs_dict["evidence"] = obs.evidence observations_data.append(obs_dict) heuristics_data: list[dict[str, Any]] = [] for heur in triage_result.heuristics: heur_dict: dict[str, Any] = { "name": heur.name, "description": heur.description, "confidence": heur.confidence.value, } if heur.rule_id is not None: heur_dict["rule_id"] = heur.rule_id if heur.evidence: heur_dict["evidence"] = heur.evidence heuristics_data.append(heur_dict) unknowns_data: list[dict[str, Any]] = [] for unk in triage_result.unknowns: unk_dict: dict[str, Any] = {"question": unk.question} if unk.address is not None: unk_dict["address"] = unk.address.to_dict() if unk.category is not None: unk_dict["category"] = unk.category unknowns_data.append(unk_dict) return { "observations": observations_data, "heuristics": heuristics_data, "unknowns": unknowns_data, "partial": triage_result.partial, } def collect_focused_data( adapter: Any, binary: Any, selector: str, ) -> dict[str, Any]: """Collect focused analysis data for a specific entity. Args: adapter: Backend adapter instance. binary: Binary domain entity. selector: Entity selector string (e.g., "function:main"). Returns: Dict with focused entity data (decompilation, xrefs, etc.). """ selector_lower = selector.lower() data: dict[str, Any] = {"selector": selector, "entity_type": "unknown"} if selector_lower.startswith("function:"): func_name = selector.split(":", 1)[1] try: functions = adapter.get_functions(binary) target = None for f in functions: if f.name == func_name or ( f.address is not None and f.address.display == func_name ): target = f break if target is None: data["error"] = f"Function not found: {func_name}" return data data["entity_type"] = "function" data["entity"] = { "name": target.name, "address": target.address.to_dict() if target.address else None, "size_bytes": target.size_bytes, "confidence": target.confidence.value, "name_source": target.name_source.value, } # Decompile try: decomp = adapter.decompile(binary, target) data["pseudocode"] = decomp.pseudocode if decomp else "" if decomp and decomp.address_map: data["address_map"] = [ { "line": am.line, "address": am.address.to_dict() if am.address else None, } for am in decomp.address_map ] except Exception: data["pseudocode"] = "(decompilation not available)" # Xrefs try: xrefs = adapter.get_xrefs(binary, target) data["xrefs"] = [ { "from": x.from_addr.to_dict() if x.from_addr else None, "to": x.to_addr.to_dict() if x.to_addr else None, "kind": x.kind.value, "confidence": x.confidence.value, } for x in xrefs ] except Exception: data["xrefs"] = [] # Callers / Callees try: callers = adapter.get_callers(binary, target) data["callers"] = [ { "name": c.caller_name if hasattr(c, "caller_name") else "unknown", "address": c.caller_address.to_dict() if hasattr(c, "caller_address") and c.caller_address else None, } for c in callers ] except Exception: data["callers"] = [] try: callees = adapter.get_callees(binary, target) data["callees"] = [ { "name": c.callee_name if hasattr(c, "callee_name") else "unknown", "address": c.callee_address.to_dict() if hasattr(c, "callee_address") and c.callee_address else None, } for c in callees ] except Exception: data["callees"] = [] except Exception as e: data["error"] = str(e) return data def collect_project_data( manifest: dict[str, Any], adapter: Any, binary: Any, ) -> dict[str, Any]: """Collect full project analysis state summary. Args: manifest: Project manifest dict. adapter: Backend adapter instance. binary: Binary domain entity. Returns: Dict with project metadata, sections, functions, etc. """ data: dict[str, Any] = { "project": { "name": manifest.get("name", "unknown"), "state": manifest.get("state", "unknown"), "created_at": manifest.get("created_at"), "binary_count": manifest.get("binary_count", 0), "is_stale": manifest.get("is_stale", False), }, "binary": { "id": str(binary.id) if binary else None, "sha256": binary.sha256 if binary else None, "format": binary.format if binary else None, "architecture": binary.architecture if binary else None, "size_bytes": binary.size_bytes if binary else 0, }, "sections": [], "functions": [], "imports": [], "exports": [], } if binary is None: return data # Sections try: sections = adapter.get_sections(binary) data["sections"] = [ { "name": s.name, "address": s.address.to_dict() if s.address else None, "virtual_size": s.virtual_size, "raw_size": s.raw_size, "flags": s.flags, "entropy": s.entropy, } for s in sections ] except Exception: pass # Functions try: functions = adapter.get_functions(binary) data["functions"] = [ { "name": f.name, "address": f.address.to_dict() if f.address else None, "size_bytes": f.size_bytes, "confidence": f.confidence.value, } for f in functions[:100] ] data["function_count"] = len(functions) except Exception: data["function_count"] = 0 # Imports try: imports = adapter.get_imports(binary) data["imports"] = [ { "module": imp.module, "symbol": imp.symbol, "resolution": imp.resolution.value, } for imp in imports[:100] ] data["import_count"] = len(imports) except Exception: data["import_count"] = 0 # Exports try: exports = adapter.get_exports(binary) data["exports"] = [ { "name": exp.name, "address": exp.address.to_dict() if exp.address else None, "kind": exp.kind, } for exp in exports[:100] ] data["export_count"] = len(exports) except Exception: data["export_count"] = 0 return data # --------------------------------------------------------------------------- # JSON report generation # --------------------------------------------------------------------------- def generate_json_report( report_type: ReportType, report_data: dict[str, Any], methodology: dict[str, Any], provenance: dict[str, Any], ) -> str: """Generate a JSON report as a string. The JSON output uses the canonical domain model schemas and is considered an authoritative format alongside Markdown per ADR-008. Args: report_type: Type of report (triage, focused, project). report_data: The collected report data. methodology: Methodology section dict. provenance: Provenance section dict. Returns: JSON string with the complete report envelope. """ report = { "schema_version": "1.0.0", "report_type": report_type.value, "methodology": methodology, "provenance": provenance, "data": report_data, } return json.dumps(report, indent=2, ensure_ascii=False) # --------------------------------------------------------------------------- # Markdown report generation # --------------------------------------------------------------------------- # At module level for reusability in HTML conversion _MD_HEADER_TPL = """# Binary Analysis Report **Type:** {report_type} **Generated:** {generated_at} --- ## Methodology | Field | Value | |-------|-------| | Profile | {profile} | | Rules Version | {rules_version} | | Backend | {backend} | | Adapter | {adapter} | ### Parameters {parameters} --- ## Provenance | Field | Value | |-------|-------| | CLI Version | {cli_version} | | Project ID | {project_id} | | Binary ID | {binary_id} | | Binary SHA-256 | {binary_sha256} | | Analysis ID | {analysis_id} | | Generated At | {generated_at} | --- """ def _format_parameters(parameters: dict[str, Any]) -> str: """Format parameters dict as markdown table rows.""" if not parameters: return "No parameter overrides." lines = ["| Parameter | Value |", "|-----------|-------|"] for k, v in parameters.items(): lines.append(f"| {k} | {v} |") return "\n".join(lines) def _format_address(addr: dict[str, Any] | None) -> str: """Format an address dict as a markdown code span.""" if addr is None: return "`(null)`" return f"`{addr.get('display', addr.get('offset', 'unknown'))}`" def generate_markdown_report( report_type: ReportType, report_data: dict[str, Any], methodology: dict[str, Any], provenance: dict[str, Any], ) -> str: """Generate a self-contained Markdown report. The Markdown output is self-contained with structured sections, headings, tables, and code blocks. It is an authoritative format alongside JSON per ADR-008. Args: report_type: Type of report (triage, focused, project). report_data: The collected report data. methodology: Methodology section dict. provenance: Provenance section dict. Returns: Markdown string with the complete report. """ lines: list[str] = [] # Header lines.append( _MD_HEADER_TPL.format( report_type=report_type.value, generated_at=provenance.get("generated_at", ""), profile=methodology.get("profile", "unknown"), rules_version=methodology.get("rules_version", "unknown"), backend=methodology.get("backend", "unknown"), adapter=methodology.get("adapter", "unknown"), parameters=_format_parameters(methodology.get("parameters", {})), cli_version=provenance.get("cli_version", "unknown"), project_id=provenance.get("project_id", "N/A"), binary_id=provenance.get("binary_id", "N/A"), binary_sha256=provenance.get("binary_sha256", "N/A"), analysis_id=provenance.get("analysis_id", "N/A"), ) ) # Report-specific content if report_type == ReportType.TRIAGE: _build_md_triage(lines, report_data) elif report_type == ReportType.FOCUSED: _build_md_focused(lines, report_data) elif report_type == ReportType.PROJECT: _build_md_project(lines, report_data) return "\n".join(lines) def _build_md_triage(lines: list[str], data: dict[str, Any]) -> None: """Build Markdown sections for a triage report.""" lines.append("## Triage Analysis\n") # Observations observations = data.get("observations", []) lines.append(f"### Observations ({len(observations)})\n") if observations: lines.append("| Category | Description | Source | Address |") lines.append("|----------|-------------|--------|---------|") for obs in observations: addr = _format_address(obs.get("address")) lines.append( f"| {obs.get('category', '')} | {obs.get('description', '')} " f"| {obs.get('source', '')} | {addr} |" ) else: lines.append("_No observations recorded._") lines.append("") # Heuristics heuristics = data.get("heuristics", []) lines.append(f"### Heuristics ({len(heuristics)})\n") if heuristics: lines.append("| Name | Description | Confidence | Rule ID |") lines.append("|------|-------------|------------|---------|") for heur in heuristics: lines.append( f"| {heur.get('name', '')} | {heur.get('description', '')} " f"| {heur.get('confidence', '')} | {heur.get('rule_id', '')} |" ) else: lines.append("_No heuristics generated._") lines.append("") # Unknowns unknowns = data.get("unknowns", []) lines.append(f"### Unknowns ({len(unknowns)})\n") if unknowns: for unk in unknowns: addr = _format_address(unk.get("address")) lines.append(f"- **Q:** {unk.get('question', '')} ") lines.append(f" Address: {addr} ") if unk.get("category"): lines.append(f" Category: {unk['category']} ") else: lines.append("_No unresolved questions._") lines.append("") if data.get("partial"): lines.append("> **Note:** This report contains partial results.\n") def _build_md_focused(lines: list[str], data: dict[str, Any]) -> None: """Build Markdown sections for a focused report.""" lines.append("## Focused Analysis\n") lines.append(f"**Selector:** `{data.get('selector', 'N/A')}`\n") entity = data.get("entity", {}) entity_type = data.get("entity_type", "unknown") if entity: lines.append(f"### {entity_type.title()}: {entity.get('name', 'unknown')}\n") lines.append("| Property | Value |") lines.append("|----------|-------|") for k, v in entity.items(): if k == "address" and isinstance(v, dict): lines.append(f"| {k} | {_format_address(v)} |") else: lines.append(f"| {k} | {v} |") lines.append("") # Pseudocode pseudocode = data.get("pseudocode") if pseudocode: lines.append("### Pseudocode\n") lines.append("```c") lines.append(pseudocode) lines.append("```\n") # Xrefs xrefs = data.get("xrefs", []) if xrefs: lines.append(f"### Cross-References ({len(xrefs)})\n") lines.append("| From | To | Kind | Confidence |") lines.append("|------|----|------|------------|") for x in xrefs: lines.append( f"| {_format_address(x.get('from'))} | {_format_address(x.get('to'))} " f"| {x.get('kind', '')} | {x.get('confidence', '')} |" ) lines.append("") # Callers callers = data.get("callers", []) if callers: lines.append(f"### Callers ({len(callers)})\n") lines.append("| Name | Address |") lines.append("|------|---------|") for c in callers: lines.append(f"| {c.get('name', '?')} | {_format_address(c.get('address'))} |") lines.append("") # Callees callees = data.get("callees", []) if callees: lines.append(f"### Callees ({len(callees)})\n") lines.append("| Name | Address |") lines.append("|------|---------|") for c in callees: lines.append(f"| {c.get('name', '?')} | {_format_address(c.get('address'))} |") lines.append("") if data.get("error"): lines.append(f"> **Error:** {data['error']}\n") def _build_md_project(lines: list[str], data: dict[str, Any]) -> None: """Build Markdown sections for a project report.""" lines.append("## Project Summary\n") # Project metadata proj = data.get("project", {}) bin_info = data.get("binary", {}) lines.append("### Project\n") lines.append("| Property | Value |") lines.append("|----------|-------|") lines.append(f"| Name | {proj.get('name', 'unknown')} |") lines.append(f"| State | {proj.get('state', 'unknown')} |") lines.append(f"| Created | {proj.get('created_at', 'N/A')} |") lines.append(f"| Binary Count | {proj.get('binary_count', 0)} |") lines.append(f"| Stale | {proj.get('is_stale', False)} |") lines.append("") # Binary info lines.append("### Binary\n") lines.append("| Property | Value |") lines.append("|----------|-------|") lines.append(f"| ID | `{bin_info.get('id', 'N/A')}` |") lines.append(f"| SHA-256 | `{bin_info.get('sha256', 'N/A')}` |") lines.append(f"| Format | {bin_info.get('format', 'N/A')} |") lines.append(f"| Architecture | {bin_info.get('architecture', 'N/A')} |") lines.append(f"| Size | {bin_info.get('size_bytes', 0)} bytes |") lines.append("") # Sections sections = data.get("sections", []) lines.append(f"### Sections ({len(sections)})\n") if sections: lines.append("| Name | Address | Virtual Size | Raw Size | Flags | Entropy |") lines.append("|------|---------|-------------|----------|-------|---------|") for s in sections: lines.append( f"| {s.get('name', '')} | {_format_address(s.get('address'))} " f"| {s.get('virtual_size', 0)} | {s.get('raw_size', 0)} " f"| {', '.join(s.get('flags', []))} | {s.get('entropy', 'N/A')} |" ) else: lines.append("_No sections available._") lines.append("") # Functions func_count = data.get("function_count", 0) functions = data.get("functions", []) lines.append(f"### Functions ({func_count})\n") if functions: lines.append("| Name | Address | Size | Confidence |") lines.append("|------|---------|------|------------|") for f in functions[:50]: lines.append( f"| {f.get('name', '')} | {_format_address(f.get('address'))} " f"| {f.get('size_bytes', 0)} | {f.get('confidence', '')} |" ) if func_count > 50: lines.append(f"\n_Showing 50 of {func_count} functions._") else: lines.append("_No functions available._") lines.append("") # Imports imp_count = data.get("import_count", 0) imports = data.get("imports", []) lines.append(f"### Imports ({imp_count})\n") if imports: lines.append("| Module | Symbol | Resolution |") lines.append("|--------|--------|------------|") for imp in imports[:50]: lines.append( f"| {imp.get('module', '')} | {imp.get('symbol', '')} " f"| {imp.get('resolution', '')} |" ) if imp_count > 50: lines.append(f"\n_Showing 50 of {imp_count} imports._") else: lines.append("_No imports available._") lines.append("") # Exports exp_count = data.get("export_count", 0) exports = data.get("exports", []) lines.append(f"### Exports ({exp_count})\n") if exports: lines.append("| Name | Address | Kind |") lines.append("|------|---------|------|") for exp in exports[:50]: lines.append( f"| {exp.get('name', '')} | {_format_address(exp.get('address'))} " f"| {exp.get('kind', '')} |" ) if exp_count > 50: lines.append(f"\n_Showing 50 of {exp_count} exports._") else: lines.append("_No exports available._") lines.append("") # --------------------------------------------------------------------------- # HTML report generation (optional rendering) # --------------------------------------------------------------------------- def generate_html_report( report_type: ReportType, report_data: dict[str, Any], methodology: dict[str, Any], provenance: dict[str, Any], ) -> str: """Generate an HTML report derived from the canonical Markdown output. HTML is an optional rendering format (not authoritative). If a dependency is missing for rendering, the caller should fall back to the canonical Markdown path with a warning. Args: report_type: Type of report. report_data: The collected report data. methodology: Methodology section dict. provenance: Provenance section dict. Returns: HTML string with the rendered report. """ md = generate_markdown_report(report_type, report_data, methodology, provenance) # Simple built-in Markdown-to-HTML conversion (no external dependency) html_lines: list[str] = [] in_code_block = False in_table = False html_lines.append("") html_lines.append('') html_lines.append("
") html_lines.append('') html_lines.append(f"")
in_code_block = True
continue
if in_code_block:
html_lines.append(_html_escape(line))
continue
# Tables
if stripped.startswith("|") and stripped.endswith("|"):
if not in_table:
html_lines.append("")
in_table = True
cells = [c.strip() for c in stripped[1:-1].split("|")]
if all(c.startswith("-") for c in cells if c):
continue # Separator row
# Determine if header row
if html_lines[-1] == "":
tag = "th"
else:
prev = html_lines[-1]
if prev.startswith("") and " " not in prev:
tag = "th"
elif prev.startswith(""):
tag = "td"
html_lines.append(
"" + "".join(f"<{tag}>{_html_escape(c)}{tag}>" for c in cells) + " "
)
continue
else:
if in_table:
html_lines.append("
")
in_table = False
# Headings
if stripped.startswith("# "):
html_lines.append(f"{_html_escape(stripped[2:])}
")
elif stripped.startswith("## "):
html_lines.append(f"{_html_escape(stripped[3:])}
")
elif stripped.startswith("### "):
html_lines.append(f"{_html_escape(stripped[4:])}
")
elif stripped == "---":
html_lines.append("
")
elif stripped.startswith("> "):
html_lines.append(f"{_html_escape(stripped[2:])}
")
elif stripped.startswith("- "):
html_lines.append(f"{_html_escape(stripped[2:])} ")
elif stripped.startswith(" "):
html_lines.append(f"
{_html_escape(stripped)}")
elif stripped.startswith("**") and stripped.endswith("**"):
html_lines.append(f"{_html_escape(stripped[2:-2])}
")
elif stripped:
# Inline code spans
line_html = _html_escape_with_code(stripped)
if not html_lines[-1].startswith("<"):
html_lines.append(f"{line_html}
")
elif html_lines[-1] == "" or html_lines[-1].startswith("{line_html}")
else:
html_lines.append("")
if in_table:
html_lines.append("
")
if in_code_block:
html_lines.append("")
html_lines.append("")
html_lines.append("")
return "\n".join(html_lines)
def _html_escape(text: str) -> str:
"""Escape HTML special characters."""
return text.replace("&", "&").replace("<", "<").replace(">", ">")
def _html_escape_with_code(text: str) -> str:
"""Escape HTML but preserve inline code spans."""
result = _html_escape(text)
# Restore code spans: `...` -> ...
import re
result = re.sub(r"`([^`]+)`", r"\1", result)
return result
# ---------------------------------------------------------------------------
# PDF report generation (optional rendering)
# ---------------------------------------------------------------------------
def generate_pdf_report(
report_type: ReportType,
report_data: dict[str, Any],
methodology: dict[str, Any],
provenance: dict[str, Any],
) -> tuple[str | None, str | None]:
"""Attempt to generate a PDF report.
PDF is an optional rendering format (not authoritative). Requires a PDF
generation dependency. Returns (pdf_path, error_message).
If no PDF engine is available, returns (None, error_message) so the
caller can fall back with a warning and the canonical path.
Args:
report_type: Type of report.
report_data: The collected report data.
methodology: Methodology section dict.
provenance: Provenance section dict.
Returns:
Tuple of (output_path_or_None, error_message_or_None).
"""
# Try to use weasyprint if available
try:
import weasyprint # type: ignore[import-not-found] # noqa: F401
except ImportError:
pass
else:
html_content = generate_html_report(report_type, report_data, methodology, provenance)
return (html_content, None) # caller will write and convert
# Try reportlab
try:
import reportlab # type: ignore[import-untyped] # noqa: F401
except ImportError:
pass
else:
html_content = generate_html_report(report_type, report_data, methodology, provenance)
return (html_content, None)
# No PDF engine available
return (None, "PDF rendering dependency unavailable (install weasyprint or reportlab)")
# ---------------------------------------------------------------------------
# High-level report generation
# ---------------------------------------------------------------------------
def write_report(
project_path: str,
report_type: ReportType,
output_format: str,
report_data: dict[str, Any],
methodology: dict[str, Any],
provenance: dict[str, Any],
) -> tuple[str, list[str]]:
"""Write a report file to the project's reports/ directory.
Markdown and JSON are authoritative formats. HTML and PDF are optional
renderings derived from the canonical formats.
Args:
project_path: Absolute path to the project workspace.
report_type: Type of report (triage, focused, project).
output_format: Output format (markdown, json, html, pdf).
report_data: The collected report data.
methodology: Methodology section dict.
provenance: Provenance section dict.
Returns:
Tuple of (output_path, warnings_list).
Raises:
ValueError: If output_format is unsupported.
"""
reports_dir = os.path.join(project_path, "reports")
os.makedirs(reports_dir, exist_ok=True)
analysis_id = provenance.get("analysis_id", "unknown")[:8]
fmt_ext = output_format.lower()
# "markdown" maps to .md
if fmt_ext == "markdown":
fmt_ext = "md"
filename = f"report-{report_type.value.lower()}-{analysis_id}.{fmt_ext}"
output_path = os.path.join(reports_dir, filename)
warnings: list[str] = []
if output_format in ("markdown", "md"):
content = generate_markdown_report(report_type, report_data, methodology, provenance)
with open(output_path, "w", encoding="utf-8") as f:
f.write(content)
elif output_format == "json":
content = generate_json_report(report_type, report_data, methodology, provenance)
with open(output_path, "w", encoding="utf-8") as f:
f.write(content)
elif output_format == "html":
content = generate_html_report(report_type, report_data, methodology, provenance)
with open(output_path, "w", encoding="utf-8") as f:
f.write(content)
elif output_format == "pdf":
html_content, pdf_error = generate_pdf_report(
report_type, report_data, methodology, provenance
)
if pdf_error is not None:
# PDF engine unavailable; write canonical Markdown instead
md_filename = f"report-{report_type.value.lower()}-{analysis_id}.md"
md_path = os.path.join(reports_dir, md_filename)
content = generate_markdown_report(report_type, report_data, methodology, provenance)
with open(md_path, "w", encoding="utf-8") as f:
f.write(content)
warnings.append(f"{pdf_error}. Wrote canonical Markdown report instead at: {md_path}")
return md_path, warnings
# Write the HTML, then convert to PDF if weasyprint is available
try:
import weasyprint
pdf_bytes = weasyprint.HTML(string=html_content).write_pdf()
with open(output_path, "wb") as f:
f.write(pdf_bytes)
except (ImportError, Exception) as e:
# Fall back to canonical Markdown
md_filename = f"report-{report_type.value.lower()}-{analysis_id}.md"
md_path = os.path.join(reports_dir, md_filename)
content = generate_markdown_report(report_type, report_data, methodology, provenance)
with open(md_path, "w", encoding="utf-8") as f:
f.write(content)
warnings.append(
f"PDF rendering failed: {e}. Wrote canonical Markdown report instead at: {md_path}"
)
return md_path, warnings
else:
raise ValueError(f"Unsupported output format: {output_format}")
return output_path, warnings