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

1"""HTML report export for TraceKit. 

2 

3This module provides interactive HTML report generation with embedded Plotly charts, 

4measurement tables, and custom styling/theming. 

5 

6 

7Example: 

8 >>> from tracekit.exporters.html_export import export_html 

9 >>> export_html(measurements, "report.html", title="Analysis Report") 

10""" 

11 

12from __future__ import annotations 

13 

14import base64 

15from datetime import datetime 

16from io import BytesIO 

17from pathlib import Path 

18from typing import Any 

19 

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 }} 

42 

43 {dark_mode_styles} 

44 

45 * {{ 

46 box-sizing: border-box; 

47 margin: 0; 

48 padding: 0; 

49 }} 

50 

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 }} 

59 

60 .container {{ 

61 max-width: 1200px; 

62 margin: 0 auto; 

63 }} 

64 

65 header {{ 

66 margin-bottom: 30px; 

67 border-bottom: 3px solid var(--primary-color); 

68 padding-bottom: 20px; 

69 }} 

70 

71 h1 {{ 

72 font-size: 28px; 

73 color: var(--primary-color); 

74 margin-bottom: 10px; 

75 }} 

76 

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 }} 

85 

86 h3 {{ 

87 font-size: 16px; 

88 color: var(--primary-color); 

89 margin-top: 20px; 

90 margin-bottom: 10px; 

91 }} 

92 

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 }} 

100 

101 .metadata span {{ 

102 margin-right: 20px; 

103 }} 

104 

105 table {{ 

106 width: 100%; 

107 border-collapse: collapse; 

108 margin: 15px 0; 

109 }} 

110 

111 th, td {{ 

112 padding: 10px 12px; 

113 text-align: left; 

114 border: 1px solid var(--border-color); 

115 }} 

116 

117 th {{ 

118 background-color: var(--table-header-bg); 

119 font-weight: 600; 

120 }} 

121 

122 tr:nth-child(even) {{ 

123 background-color: var(--table-alt-row-bg); 

124 }} 

125 

126 tr:hover {{ 

127 background-color: rgba(52, 152, 219, 0.1); 

128 }} 

129 

130 .pass {{ 

131 color: var(--success-color); 

132 font-weight: 600; 

133 }} 

134 

135 .pass::before {{ 

136 content: '\\2713 '; 

137 }} 

138 

139 .fail {{ 

140 color: var(--danger-color); 

141 font-weight: 600; 

142 }} 

143 

144 .fail::before {{ 

145 content: '\\2717 '; 

146 }} 

147 

148 .warning {{ 

149 color: var(--warning-color); 

150 font-weight: 600; 

151 }} 

152 

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 }} 

159 

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 }} 

167 

168 .plot-container img {{ 

169 max-width: 100%; 

170 height: auto; 

171 display: block; 

172 margin: 0 auto; 

173 }} 

174 

175 .plot-caption {{ 

176 text-align: center; 

177 font-style: italic; 

178 margin-top: 10px; 

179 color: #666; 

180 }} 

181 

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 }} 

190 

191 @media (max-width: 768px) {{ 

192 body {{ 

193 padding: 10px; 

194 }} 

195 

196 h1 {{ 

197 font-size: 22px; 

198 }} 

199 

200 table {{ 

201 font-size: 12px; 

202 }} 

203 

204 th, td {{ 

205 padding: 6px 8px; 

206 }} 

207 }} 

208 

209 @media print {{ 

210 body {{ 

211 padding: 0; 

212 }} 

213 

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> 

228 

229 {summary_html} 

230 

231 {measurements_html} 

232 

233 {plots_html} 

234 

235 {conclusions_html} 

236 

237 <footer> 

238 Generated by TraceKit &middot; {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 } 

254 

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""" 

263 

264 

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. 

278 

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). 

295 

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 ) 

309 

310 Path(path).write_text(html_content, encoding="utf-8") 

311 

312 

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. 

325 

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). 

335 

336 Returns: 

337 HTML content as string. 

338 

339 References: 

340 EXP-007 

341 """ 

342 # Metadata HTML 

343 metadata_parts = [] 

344 metadata = data.get("metadata", {}) 

345 

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>") 

348 

349 timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") 

350 metadata_parts.append(f"<span><strong>Generated:</strong> {timestamp}</span>") 

351 

352 if "filename" in metadata: 

353 metadata_parts.append( 

354 f"<span><strong>Source:</strong> {_html_escape(metadata['filename'])}</span>" 

355 ) 

356 

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>") 

361 

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>") 

364 

365 metadata_html = "\n ".join(metadata_parts) 

366 

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"]) 

382 

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) 

388 

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 "" 

400 

401 # Body class for dark mode 

402 body_class = ' class="dark-mode"' if dark_mode else "" 

403 

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 ) 

417 

418 return html 

419 

420 

421def _html_escape(text: str) -> str: 

422 """Escape HTML special characters.""" 

423 return ( 

424 text.replace("&", "&amp;") 

425 .replace("<", "&lt;") 

426 .replace(">", "&gt;") 

427 .replace('"', "&quot;") 

428 .replace("'", "&#39;") 

429 ) 

430 

431 

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" 

442 

443 

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" 

448 

449 abs_val = abs(value) 

450 

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" 

463 

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" 

474 

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}" 

479 

480 

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 "" 

485 

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", "") 

492 

493 val_str = _format_value(val, unit) if isinstance(val, float) else str(val) 

494 

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)) 

505 

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) 

514 

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 ) 

520 

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 """ 

539 

540 

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. 

547 

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. 

552 

553 Returns: 

554 Tuple of (plots_html, plotly_script_tag) 

555 """ 

556 if not plots: 

557 return "", "" 

558 

559 plot_divs = [] 

560 has_plotly = False 

561 

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}" 

569 

570 if fig is None: 

571 continue 

572 

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 

581 

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 

593 

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") 

606 

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)}">' 

615 

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 ) 

623 

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>' 

628 

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 ) 

639 

640 return plots_html, plotly_script 

641 

642 

643def _try_render_plotly(fig: Any, interactive: bool) -> str | None: 

644 """Try to render a Plotly figure to HTML. 

645 

646 Args: 

647 fig: Figure object to render (may be Plotly figure or other type). 

648 interactive: Enable interactive Plotly rendering. 

649 

650 Returns: 

651 HTML string if successful, None if not a Plotly figure. 

652 """ 

653 if not interactive: 

654 return None 

655 

656 try: 

657 import plotly.graph_objects as go # type: ignore[import-not-found] 

658 

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 

667 

668 return None 

669 

670 

671def _try_render_matplotlib(fig: Any, self_contained: bool) -> str | None: 

672 """Try to render a Matplotlib figure to HTML. 

673 

674 Args: 

675 fig: Figure object to render (may be matplotlib figure or other type). 

676 self_contained: Embed image as base64 data URI. 

677 

678 Returns: 

679 HTML img tag if successful, None if not a Matplotlib figure. 

680 """ 

681 try: 

682 import matplotlib.pyplot as plt 

683 

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 

694 

695 return None 

696 

697 

698__all__ = [ 

699 "export_html", 

700 "generate_html_report", 

701]