Coverage for src / tracekit / exporters / html_export.py: 48%
161 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 23:04 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 23:04 +0000
1"""HTML report export for TraceKit.
3This module provides interactive HTML report generation with embedded Plotly charts,
4measurement tables, and custom styling/theming.
7Example:
8 >>> from tracekit.exporters.html_export import export_html
9 >>> export_html(measurements, "report.html", title="Analysis Report")
10"""
12from __future__ import annotations
14import base64
15from datetime import datetime
16from io import BytesIO
17from pathlib import Path
18from typing import Any
20# HTML template with modern styling
21HTML_TEMPLATE = """<!DOCTYPE html>
22<html lang="en">
23<head>
24 <meta charset="UTF-8">
25 <meta name="viewport" content="width=device-width, initial-scale=1.0">
26 <meta name="generator" content="TraceKit Export">
27 <title>{title}</title>
28 {plotly_script}
29 <style>
30 :root {{
31 --primary-color: #2c3e50;
32 --secondary-color: #3498db;
33 --success-color: #27ae60;
34 --warning-color: #f39c12;
35 --danger-color: #e74c3c;
36 --bg-color: #ffffff;
37 --text-color: #333333;
38 --border-color: #dddddd;
39 --table-header-bg: #f2f2f2;
40 --table-alt-row-bg: #f9f9f9;
41 }}
43 {dark_mode_styles}
45 * {{
46 box-sizing: border-box;
47 margin: 0;
48 padding: 0;
49 }}
51 body {{
52 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
53 font-size: 14px;
54 line-height: 1.6;
55 color: var(--text-color);
56 background-color: var(--bg-color);
57 padding: 20px;
58 }}
60 .container {{
61 max-width: 1200px;
62 margin: 0 auto;
63 }}
65 header {{
66 margin-bottom: 30px;
67 border-bottom: 3px solid var(--primary-color);
68 padding-bottom: 20px;
69 }}
71 h1 {{
72 font-size: 28px;
73 color: var(--primary-color);
74 margin-bottom: 10px;
75 }}
77 h2 {{
78 font-size: 20px;
79 color: var(--primary-color);
80 margin-top: 30px;
81 margin-bottom: 15px;
82 border-bottom: 1px solid var(--border-color);
83 padding-bottom: 8px;
84 }}
86 h3 {{
87 font-size: 16px;
88 color: var(--primary-color);
89 margin-top: 20px;
90 margin-bottom: 10px;
91 }}
93 .metadata {{
94 background-color: var(--table-alt-row-bg);
95 padding: 15px;
96 border-radius: 5px;
97 font-size: 13px;
98 color: #666;
99 }}
101 .metadata span {{
102 margin-right: 20px;
103 }}
105 table {{
106 width: 100%;
107 border-collapse: collapse;
108 margin: 15px 0;
109 }}
111 th, td {{
112 padding: 10px 12px;
113 text-align: left;
114 border: 1px solid var(--border-color);
115 }}
117 th {{
118 background-color: var(--table-header-bg);
119 font-weight: 600;
120 }}
122 tr:nth-child(even) {{
123 background-color: var(--table-alt-row-bg);
124 }}
126 tr:hover {{
127 background-color: rgba(52, 152, 219, 0.1);
128 }}
130 .pass {{
131 color: var(--success-color);
132 font-weight: 600;
133 }}
135 .pass::before {{
136 content: '\\2713 ';
137 }}
139 .fail {{
140 color: var(--danger-color);
141 font-weight: 600;
142 }}
144 .fail::before {{
145 content: '\\2717 ';
146 }}
148 .warning {{
149 color: var(--warning-color);
150 font-weight: 600;
151 }}
153 .summary {{
154 background-color: rgba(52, 152, 219, 0.1);
155 border-left: 4px solid var(--secondary-color);
156 padding: 15px;
157 margin: 20px 0;
158 }}
160 .plot-container {{
161 margin: 20px 0;
162 padding: 15px;
163 background-color: var(--bg-color);
164 border: 1px solid var(--border-color);
165 border-radius: 5px;
166 }}
168 .plot-container img {{
169 max-width: 100%;
170 height: auto;
171 display: block;
172 margin: 0 auto;
173 }}
175 .plot-caption {{
176 text-align: center;
177 font-style: italic;
178 margin-top: 10px;
179 color: #666;
180 }}
182 footer {{
183 margin-top: 40px;
184 padding-top: 20px;
185 border-top: 1px solid var(--border-color);
186 text-align: center;
187 font-size: 12px;
188 color: #888;
189 }}
191 @media (max-width: 768px) {{
192 body {{
193 padding: 10px;
194 }}
196 h1 {{
197 font-size: 22px;
198 }}
200 table {{
201 font-size: 12px;
202 }}
204 th, td {{
205 padding: 6px 8px;
206 }}
207 }}
209 @media print {{
210 body {{
211 padding: 0;
212 }}
214 .container {{
215 max-width: 100%;
216 }}
217 }}
218 </style>
219</head>
220<body{body_class}>
221 <div class="container">
222 <header>
223 <h1>{title}</h1>
224 <div class="metadata">
225 {metadata_html}
226 </div>
227 </header>
229 {summary_html}
231 {measurements_html}
233 {plots_html}
235 {conclusions_html}
237 <footer>
238 Generated by TraceKit · {timestamp}
239 </footer>
240 </div>
241</body>
242</html>
243"""
244DARK_MODE_CSS = """
245 @media (prefers-color-scheme: dark) {
246 :root {
247 --bg-color: #1e1e1e;
248 --text-color: #e0e0e0;
249 --border-color: #444444;
250 --table-header-bg: #2d2d2d;
251 --table-alt-row-bg: #252525;
252 }
253 }
255 body.dark-mode {
256 --bg-color: #1e1e1e;
257 --text-color: #e0e0e0;
258 --border-color: #444444;
259 --table-header-bg: #2d2d2d;
260 --table-alt-row-bg: #252525;
261 }
262"""
265def export_html(
266 data: dict[str, Any],
267 path: str | Path,
268 *,
269 title: str = "TraceKit Analysis Report",
270 author: str | None = None,
271 include_plots: bool = True,
272 self_contained: bool = True,
273 interactive: bool = True,
274 dark_mode: bool = False,
275 theme: str | None = None,
276) -> None:
277 """Export measurement results to interactive HTML format.
279 Args:
280 data: Dictionary containing measurement results, plots, and metadata.
281 Expected keys:
282 - "measurements": dict of name -> value pairs
283 - "plots": list of matplotlib/plotly figures or paths
284 - "metadata": optional dict of metadata
285 - "summary": optional executive summary text
286 - "conclusions": optional conclusions text
287 path: Output file path.
288 title: Report title.
289 author: Author name (optional).
290 include_plots: Include plots in report.
291 self_contained: Embed all resources inline (True) or save separately.
292 interactive: Use Plotly for interactive charts when available.
293 dark_mode: Enable dark mode styling.
294 theme: Custom theme name (reserved for future use).
296 References:
297 EXP-007
298 """
299 html_content = generate_html_report(
300 data,
301 title=title,
302 author=author,
303 include_plots=include_plots,
304 self_contained=self_contained,
305 interactive=interactive,
306 dark_mode=dark_mode,
307 theme=theme,
308 )
310 Path(path).write_text(html_content, encoding="utf-8")
313def generate_html_report(
314 data: dict[str, Any],
315 *,
316 title: str = "TraceKit Analysis Report",
317 author: str | None = None,
318 include_plots: bool = True,
319 self_contained: bool = True,
320 interactive: bool = True,
321 dark_mode: bool = False,
322 theme: str | None = None,
323) -> str:
324 """Generate HTML report as string.
326 Args:
327 data: Dictionary containing measurement results, plots, and metadata.
328 title: Report title.
329 author: Author name (optional).
330 include_plots: Include plots in report.
331 self_contained: Embed all resources inline.
332 interactive: Use Plotly for interactive charts when available.
333 dark_mode: Enable dark mode styling.
334 theme: Custom theme name (reserved for future use).
336 Returns:
337 HTML content as string.
339 References:
340 EXP-007
341 """
342 # Metadata HTML
343 metadata_parts = []
344 metadata = data.get("metadata", {})
346 if author: 346 ↛ 347line 346 didn't jump to line 347 because the condition on line 346 was never true
347 metadata_parts.append(f"<span><strong>Author:</strong> {author}</span>")
349 timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
350 metadata_parts.append(f"<span><strong>Generated:</strong> {timestamp}</span>")
352 if "filename" in metadata:
353 metadata_parts.append(
354 f"<span><strong>Source:</strong> {_html_escape(metadata['filename'])}</span>"
355 )
357 if "sample_rate" in metadata:
358 sr = metadata["sample_rate"]
359 sr_str = _format_sample_rate(sr)
360 metadata_parts.append(f"<span><strong>Sample Rate:</strong> {sr_str}</span>")
362 if "samples" in metadata: 362 ↛ 363line 362 didn't jump to line 363 because the condition on line 362 was never true
363 metadata_parts.append(f"<span><strong>Samples:</strong> {metadata['samples']:,}</span>")
365 metadata_html = "\n ".join(metadata_parts)
367 # Summary HTML
368 summary_html = ""
369 if "summary" in data:
370 summary_html = f"""
371 <section id="summary">
372 <h2>Executive Summary</h2>
373 <div class="summary">
374 <p>{_html_escape(data["summary"])}</p>
375 </div>
376 </section>
377 """
378 # Measurements HTML
379 measurements_html = ""
380 if "measurements" in data: 380 ↛ 384line 380 didn't jump to line 384 because the condition on line 380 was always true
381 measurements_html = _generate_measurements_html(data["measurements"])
383 # Plots HTML
384 plots_html = ""
385 plotly_script = ""
386 if include_plots and "plots" in data: 386 ↛ 387line 386 didn't jump to line 387 because the condition on line 386 was never true
387 plots_html, plotly_script = _generate_plots_html(data["plots"], self_contained, interactive)
389 # Conclusions HTML
390 conclusions_html = ""
391 if "conclusions" in data:
392 conclusions_html = f"""
393 <section id="conclusions">
394 <h2>Conclusions</h2>
395 <p>{_html_escape(data["conclusions"])}</p>
396 </section>
397 """
398 # Dark mode styles
399 dark_mode_styles = DARK_MODE_CSS if dark_mode else ""
401 # Body class for dark mode
402 body_class = ' class="dark-mode"' if dark_mode else ""
404 # Generate final HTML
405 html = HTML_TEMPLATE.format(
406 title=_html_escape(title),
407 plotly_script=plotly_script,
408 dark_mode_styles=dark_mode_styles,
409 body_class=body_class,
410 metadata_html=metadata_html,
411 summary_html=summary_html,
412 measurements_html=measurements_html,
413 plots_html=plots_html,
414 conclusions_html=conclusions_html,
415 timestamp=timestamp,
416 )
418 return html
421def _html_escape(text: str) -> str:
422 """Escape HTML special characters."""
423 return (
424 text.replace("&", "&")
425 .replace("<", "<")
426 .replace(">", ">")
427 .replace('"', """)
428 .replace("'", "'")
429 )
432def _format_sample_rate(sr: float) -> str:
433 """Format sample rate with SI prefix."""
434 if sr >= 1e9: 434 ↛ 436line 434 didn't jump to line 436 because the condition on line 434 was always true
435 return f"{sr / 1e9:.3f} GS/s"
436 elif sr >= 1e6:
437 return f"{sr / 1e6:.3f} MS/s"
438 elif sr >= 1e3:
439 return f"{sr / 1e3:.3f} kS/s"
440 else:
441 return f"{sr:.3f} S/s"
444def _format_value(value: float, unit: str) -> str:
445 """Format value with appropriate precision."""
446 if value == 0: 446 ↛ 447line 446 didn't jump to line 447 because the condition on line 446 was never true
447 return "0"
449 abs_val = abs(value)
451 # Time units
452 if unit in ("s", "sec", "seconds"):
453 if abs_val >= 1.0: 453 ↛ 454line 453 didn't jump to line 454 because the condition on line 453 was never true
454 return f"{value:.6g} s"
455 elif abs_val >= 1e-3: 455 ↛ 456line 455 didn't jump to line 456 because the condition on line 455 was never true
456 return f"{value * 1e3:.6g} ms"
457 elif abs_val >= 1e-6: 457 ↛ 458line 457 didn't jump to line 458 because the condition on line 457 was never true
458 return f"{value * 1e6:.6g} us"
459 elif abs_val >= 1e-9: 459 ↛ 462line 459 didn't jump to line 462 because the condition on line 459 was always true
460 return f"{value * 1e9:.6g} ns"
461 else:
462 return f"{value * 1e12:.6g} ps"
464 # Frequency units
465 if unit in ("Hz", "hz"):
466 if abs_val >= 1e9: 466 ↛ 467line 466 didn't jump to line 467 because the condition on line 466 was never true
467 return f"{value / 1e9:.6g} GHz"
468 elif abs_val >= 1e6: 468 ↛ 470line 468 didn't jump to line 470 because the condition on line 468 was always true
469 return f"{value / 1e6:.6g} MHz"
470 elif abs_val >= 1e3:
471 return f"{value / 1e3:.6g} kHz"
472 else:
473 return f"{value:.6g} Hz"
475 # Default formatting
476 if unit: 476 ↛ 478line 476 didn't jump to line 478 because the condition on line 476 was always true
477 return f"{value:.6g} {unit}"
478 return f"{value:.6g}"
481def _generate_measurements_html(measurements: dict[str, Any]) -> str:
482 """Generate measurements table HTML."""
483 if not measurements: 483 ↛ 484line 483 didn't jump to line 484 because the condition on line 483 was never true
484 return ""
486 rows = []
487 for name, value in measurements.items():
488 if isinstance(value, dict):
489 val = value.get("value", "N/A")
490 unit = value.get("unit", "")
491 status = value.get("status", "")
493 val_str = _format_value(val, unit) if isinstance(val, float) else str(val)
495 # Status class and formatting
496 status_upper = str(status).upper()
497 if status_upper == "PASS":
498 status_html = '<span class="pass">PASS</span>'
499 elif status_upper == "FAIL": 499 ↛ 501line 499 didn't jump to line 501 because the condition on line 499 was always true
500 status_html = '<span class="fail">FAIL</span>'
501 elif status_upper == "WARNING":
502 status_html = '<span class="warning">WARNING</span>'
503 else:
504 status_html = _html_escape(str(status))
506 rows.append(
507 f"<tr><td>{_html_escape(name)}</td>"
508 f"<td>{_html_escape(val_str)}</td>"
509 f"<td>{_html_escape(unit)}</td>"
510 f"<td>{status_html}</td></tr>"
511 )
512 else:
513 val_str = f"{value:.6g}" if isinstance(value, float) else str(value)
515 rows.append(
516 f"<tr><td>{_html_escape(name)}</td>"
517 f"<td>{_html_escape(val_str)}</td>"
518 f"<td>-</td><td>-</td></tr>"
519 )
521 return f"""
522 <section id="measurements">
523 <h2>Measurement Results</h2>
524 <table>
525 <thead>
526 <tr>
527 <th>Parameter</th>
528 <th>Value</th>
529 <th>Unit</th>
530 <th>Status</th>
531 </tr>
532 </thead>
533 <tbody>
534 {"".join(rows)}
535 </tbody>
536 </table>
537 </section>
538 """
541def _generate_plots_html(
542 plots: list[Any],
543 self_contained: bool,
544 interactive: bool,
545) -> tuple[str, str]:
546 """Generate plots HTML and Plotly script if needed.
548 Args:
549 plots: List of plot objects (matplotlib figures, plotly figures, or paths).
550 self_contained: Embed all resources inline (True) or reference externally.
551 interactive: Use Plotly for interactive charts when available.
553 Returns:
554 Tuple of (plots_html, plotly_script_tag)
555 """
556 if not plots:
557 return "", ""
559 plot_divs = []
560 has_plotly = False
562 for i, plot in enumerate(plots, start=1):
563 if isinstance(plot, dict):
564 fig = plot.get("figure")
565 caption = plot.get("caption", f"Figure {i}")
566 else:
567 fig = plot
568 caption = f"Figure {i}"
570 if fig is None:
571 continue
573 # Check if it's a Plotly figure
574 plotly_html = _try_render_plotly(fig, interactive)
575 if plotly_html:
576 has_plotly = True
577 plot_divs.append(
578 f'<div class="plot-container"><h3>{_html_escape(caption)}</h3>{plotly_html}</div>'
579 )
580 continue
582 # Try matplotlib figure
583 img_html = _try_render_matplotlib(fig, self_contained)
584 if img_html:
585 plot_divs.append(
586 f'<div class="plot-container">'
587 f"<h3>{_html_escape(caption)}</h3>"
588 f"{img_html}"
589 f'<div class="plot-caption">{_html_escape(caption)}</div>'
590 f"</div>"
591 )
592 continue
594 # Image path
595 if isinstance(fig, str | Path):
596 if self_contained:
597 try:
598 img_data = Path(fig).read_bytes()
599 img_ext = Path(fig).suffix.lower()
600 mime_type = {
601 ".png": "image/png",
602 ".jpg": "image/jpeg",
603 ".jpeg": "image/jpeg",
604 ".svg": "image/svg+xml",
605 }.get(img_ext, "image/png")
607 b64 = base64.b64encode(img_data).decode("utf-8")
608 img_html = (
609 f'<img src="data:{mime_type};base64,{b64}" alt="{_html_escape(caption)}">'
610 )
611 except Exception:
612 img_html = f"<p><em>Unable to embed image: {fig}</em></p>"
613 else:
614 img_html = f'<img src="{_html_escape(str(fig))}" alt="{_html_escape(caption)}">'
616 plot_divs.append(
617 f'<div class="plot-container">'
618 f"<h3>{_html_escape(caption)}</h3>"
619 f"{img_html}"
620 f'<div class="plot-caption">{_html_escape(caption)}</div>'
621 f"</div>"
622 )
624 # Plotly CDN script (only included if we have Plotly figures)
625 plotly_script = ""
626 if has_plotly:
627 plotly_script = '<script src="https://cdn.plot.ly/plotly-2.27.0.min.js"></script>'
629 plots_html = (
630 f"""
631 <section id="plots">
632 <h2>Plots and Visualizations</h2>
633 {"".join(plot_divs)}
634 </section>
635 """
636 if plot_divs
637 else ""
638 )
640 return plots_html, plotly_script
643def _try_render_plotly(fig: Any, interactive: bool) -> str | None:
644 """Try to render a Plotly figure to HTML.
646 Args:
647 fig: Figure object to render (may be Plotly figure or other type).
648 interactive: Enable interactive Plotly rendering.
650 Returns:
651 HTML string if successful, None if not a Plotly figure.
652 """
653 if not interactive:
654 return None
656 try:
657 import plotly.graph_objects as go # type: ignore[import-not-found]
659 if isinstance(fig, go.Figure):
660 return fig.to_html( # type: ignore[no-any-return]
661 full_html=False,
662 include_plotlyjs=False,
663 config={"displayModeBar": True, "responsive": True},
664 )
665 except ImportError:
666 pass
668 return None
671def _try_render_matplotlib(fig: Any, self_contained: bool) -> str | None:
672 """Try to render a Matplotlib figure to HTML.
674 Args:
675 fig: Figure object to render (may be matplotlib figure or other type).
676 self_contained: Embed image as base64 data URI.
678 Returns:
679 HTML img tag if successful, None if not a Matplotlib figure.
680 """
681 try:
682 import matplotlib.pyplot as plt
684 if hasattr(fig, "savefig"):
685 buf = BytesIO()
686 fig.savefig(buf, format="png", dpi=150, bbox_inches="tight")
687 buf.seek(0)
688 b64 = base64.b64encode(buf.read()).decode("utf-8")
689 return f'<img src="data:image/png;base64,{b64}" alt="Figure">'
690 except ImportError:
691 pass
692 except Exception:
693 pass
695 return None
698__all__ = [
699 "export_html",
700 "generate_html_report",
701]