modelflowib 2.73__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
modelhtml.py ADDED
@@ -0,0 +1,606 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ modelhtml.py — HTML report generation for ModelFlow model objects.
4
+
5
+ Currently contains:
6
+ MakeModelReport – self-contained HTML report for Makemodel / Listmodels
7
+ """
8
+
9
+ from dataclasses import dataclass
10
+ from typing import List, Any
11
+ import re
12
+ import html as _mmr_html
13
+
14
+
15
+ # ---------------------------------------------------------------------------
16
+ # Internal helpers
17
+ # ---------------------------------------------------------------------------
18
+
19
+ def _mmr_slugify(text: str) -> str:
20
+ """URL-safe anchor slug from a heading string."""
21
+ text = re.sub(r'[^\w\s-]', '', text.lower())
22
+ return re.sub(r'[\s_]+', '-', text).strip('-') or 'section'
23
+
24
+
25
+ def _mmr_inline_md(text: str) -> str:
26
+ """Bold, italic, code and link markdown on already-escaped HTML text."""
27
+ text = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', text)
28
+ text = re.sub(r'\*([^*\n]+?)\*', r'<em>\1</em>', text)
29
+ text = re.sub(r'`([^`]+?)`', r'<code>\1</code>', text)
30
+ text = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'<a href="\2">\1</a>', text)
31
+ return text
32
+
33
+
34
+ def _mmr_has_estimator_tag(line: str) -> bool:
35
+ """True when a source line contains an estimator/est flag."""
36
+ return bool(re.search(r'<[^>]*\b(?:estimator|est)\b[^>]*>', line, flags=re.IGNORECASE))
37
+
38
+
39
+ _BLL_IDENT = re.compile(r'\b([A-Za-z][A-Za-z0-9_]*)\b')
40
+
41
+
42
+ def _mmr_annotate_bll(text: str, var_desc: dict) -> str:
43
+ """HTML-escape BLL text, wrapping known variable names in tooltip <abbr>."""
44
+ if not var_desc:
45
+ return _mmr_html.escape(text)
46
+ esc = _mmr_html.escape
47
+ parts = []
48
+ last = 0
49
+ for m in _BLL_IDENT.finditer(text):
50
+ name = m.group(1)
51
+ parts.append(esc(text[last:m.start()]))
52
+ desc = var_desc.get(name.upper(), '') or var_desc.get(name, '')
53
+ if desc:
54
+ parts.append(
55
+ f'<abbr class="mmr-var" title="{esc(desc)}">{esc(name)}</abbr>'
56
+ )
57
+ else:
58
+ parts.append(esc(name))
59
+ last = m.end()
60
+ parts.append(esc(text[last:]))
61
+ return ''.join(parts)
62
+
63
+
64
+ def _mmr_est_panel(rec: dict, idx: int, plot_format: str) -> str:
65
+ """Return a collapsible HTML panel for one estimation record."""
66
+ esc = _mmr_html.escape
67
+ est = rec.get('estimator_object')
68
+ frml = rec.get('frmlname', '')
69
+ var = getattr(est, 'endo_var', frml) if est else frml
70
+ desc = ''
71
+ if est is not None:
72
+ omdl = getattr(est, 'omodel', None)
73
+ if omdl is not None:
74
+ desc = getattr(omdl, 'var_description', {}).get(var, '')
75
+ caption = f"Estimation: {var}" + (f" — {desc}" if desc else "")
76
+
77
+ if est is not None and hasattr(est, 'mfresult'):
78
+ try:
79
+ body = est.mfresult.get_html_report(plot_format=plot_format)
80
+ except Exception as exc:
81
+ body = f'<p class="mmr-error">Report error: {esc(str(exc))}</p>'
82
+ elif est is not None and hasattr(est, 'get_html_report'):
83
+ try:
84
+ body = est.get_html_report(plot_format=plot_format)
85
+ except Exception as exc:
86
+ body = f'<p class="mmr-error">Report error: {esc(str(exc))}</p>'
87
+ else:
88
+ org = rec.get('original_expression', '')
89
+ body = f'<p><em>Identity equation</em></p><pre><code>{esc(org)}</code></pre>'
90
+
91
+ return (
92
+ f'<button class="mmr-accordion" id="est-{idx}">{esc(caption)}</button>'
93
+ f'<div class="mmr-panel">'
94
+ f'{body}'
95
+ f'<br><a class="mmr-top" href="#top">↑ Back to top</a>'
96
+ f'</div>'
97
+ )
98
+
99
+
100
+ def _mmr_md_to_html(
101
+ md_text: str,
102
+ est_records: list,
103
+ plot_format: str = "svg",
104
+ report_all: bool = False,
105
+ var_desc: dict = None,
106
+ ) -> tuple:
107
+ """Convert markdown to HTML, inserting collapsible estimation panels.
108
+
109
+ Returns ``(html: str, toc: list[(anchor, heading_text)])``.
110
+ """
111
+ esc = _mmr_html.escape
112
+ lines = md_text.splitlines()
113
+ out: List[str] = []
114
+ toc: List[tuple] = []
115
+ anchor_counts: dict = {}
116
+ rec_i = 0
117
+ panel_i = 0
118
+
119
+ in_p = in_ul = in_ol = in_bll = in_fence = in_table = False
120
+ tbl_hdr_done = False
121
+
122
+ def ep():
123
+ nonlocal in_p
124
+ if in_p:
125
+ out.append('</p>'); in_p = False
126
+
127
+ def eul():
128
+ nonlocal in_ul
129
+ if in_ul:
130
+ out.append('</ul>'); in_ul = False
131
+
132
+ def eol():
133
+ nonlocal in_ol
134
+ if in_ol:
135
+ out.append('</ol>'); in_ol = False
136
+
137
+ def ebll():
138
+ nonlocal in_bll
139
+ if in_bll:
140
+ out.append('</code></pre>'); in_bll = False
141
+
142
+ def etbl():
143
+ nonlocal in_table, tbl_hdr_done
144
+ if in_table:
145
+ out.append('</tbody></table>')
146
+ in_table = tbl_hdr_done = False
147
+
148
+ def end_all():
149
+ ep(); eul(); eol(); ebll(); etbl()
150
+
151
+ for raw in lines:
152
+ s = raw.strip()
153
+
154
+ # code fence (```)
155
+ if s.startswith('```'):
156
+ if in_fence:
157
+ out.append('</code></pre>'); in_fence = False
158
+ else:
159
+ end_all()
160
+ lang = s[3:].strip()
161
+ cls = f' class="language-{esc(lang)}"' if lang else ''
162
+ out.append(f'<pre><code{cls}>'); in_fence = True
163
+ continue
164
+ if in_fence:
165
+ out.append(esc(raw)); continue
166
+
167
+ # empty line
168
+ if not s:
169
+ end_all(); continue
170
+
171
+ # headings ## through ####
172
+ hm = re.match(r'^(#{2,4})\s+(.+)$', s)
173
+ if hm:
174
+ end_all()
175
+ level = len(hm.group(1))
176
+ text = hm.group(2).strip()
177
+ if level == 2:
178
+ base = _mmr_slugify(text)
179
+ n = anchor_counts.get(base, 0)
180
+ anchor = base if n == 0 else f'{base}-{n}'
181
+ anchor_counts[base] = n + 1
182
+ toc.append((anchor, text))
183
+ out.append(f'<h2 id="{esc(anchor)}">{_mmr_inline_md(esc(text))}</h2>')
184
+ else:
185
+ out.append(f'<h{level}>{_mmr_inline_md(esc(text))}</h{level}>')
186
+ continue
187
+
188
+ # BLL continuation >>
189
+ if s.startswith('>>'):
190
+ content = s[2:].strip()
191
+ if not in_bll:
192
+ end_all()
193
+ out.append('<pre class="mmr-bll"><code>'); in_bll = True
194
+ out.append(_mmr_annotate_bll(content, var_desc))
195
+ continue
196
+
197
+ # BLL equation >
198
+ if s.startswith('>'):
199
+ content = s[1:].strip()
200
+ has_est = _mmr_has_estimator_tag(raw)
201
+ if not in_bll:
202
+ end_all()
203
+ out.append('<pre class="mmr-bll"><code>'); in_bll = True
204
+ out.append(_mmr_annotate_bll(content, var_desc))
205
+ if has_est and rec_i < len(est_records):
206
+ ebll()
207
+ out.append(_mmr_est_panel(est_records[rec_i], panel_i, plot_format))
208
+ panel_i += 1; rec_i += 1
209
+ continue
210
+
211
+ # markdown table row
212
+ if s.startswith('|'):
213
+ cells = [c.strip() for c in s.strip('|').split('|')]
214
+ is_sep = bool(cells) and all(re.match(r'^[-: ]+$', c) for c in cells if c)
215
+ if is_sep:
216
+ if in_table and not tbl_hdr_done:
217
+ for j in range(len(out) - 1, -1, -1):
218
+ if out[j].startswith('<tr>'):
219
+ out[j] = out[j].replace('<td>', '<th>').replace('</td>', '</th>')
220
+ break
221
+ out.append('</thead><tbody>')
222
+ tbl_hdr_done = True
223
+ continue
224
+ if not in_table:
225
+ end_all()
226
+ out.append('<table class="mmr-table"><thead>')
227
+ in_table = True; tbl_hdr_done = False
228
+ row = '<tr>' + ''.join(
229
+ f'<td>{_mmr_inline_md(esc(c))}</td>' for c in cells
230
+ ) + '</tr>'
231
+ out.append(row)
232
+ continue
233
+
234
+ if in_table:
235
+ etbl()
236
+
237
+ # unordered list
238
+ m = re.match(r'^[-*+]\s+(.+)$', s)
239
+ if m:
240
+ ep(); eol()
241
+ if not in_ul:
242
+ out.append('<ul>'); in_ul = True
243
+ out.append(f'<li>{_mmr_inline_md(esc(m.group(1)))}</li>')
244
+ continue
245
+
246
+ # ordered list
247
+ m = re.match(r'^\d+[.)]\s+(.+)$', s)
248
+ if m:
249
+ ep(); eul()
250
+ if not in_ol:
251
+ out.append('<ol>'); in_ol = True
252
+ out.append(f'<li>{_mmr_inline_md(esc(m.group(1)))}</li>')
253
+ continue
254
+
255
+ # paragraph
256
+ eul(); eol()
257
+ if not in_p:
258
+ out.append('<p>'); in_p = True
259
+ else:
260
+ out.append(' ')
261
+ out.append(_mmr_inline_md(esc(s)))
262
+
263
+ if in_fence:
264
+ out.append('</code></pre>')
265
+ end_all()
266
+
267
+ # leftover estimation records not matched to any source line
268
+ while rec_i < len(est_records):
269
+ rec = est_records[rec_i]
270
+ if rec.get('estimator_object') is not None or report_all:
271
+ out.append(_mmr_est_panel(rec, panel_i, plot_format))
272
+ panel_i += 1
273
+ rec_i += 1
274
+
275
+ return '\n'.join(out), toc
276
+
277
+
278
+ # ---------------------------------------------------------------------------
279
+ # CSS / JS templates
280
+ # ---------------------------------------------------------------------------
281
+
282
+ _MMREPORT_CSS = """\
283
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
284
+ body {
285
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, Roboto, sans-serif;
286
+ font-size: 1rem; line-height: 1.65; color: #1a1a1a; background: #fff;
287
+ max-width: 980px; margin: 0 auto; padding: 2.5rem 2rem 5rem;
288
+ }
289
+ a { color: #2563eb; text-decoration: none; }
290
+ a:hover { text-decoration: underline; }
291
+ h1 {
292
+ font-size: 2rem; font-weight: 700; letter-spacing: -.02em;
293
+ border-bottom: 2px solid #1a1a1a; padding-bottom: .5rem; margin-bottom: 1.5rem;
294
+ }
295
+ h2 {
296
+ font-size: 1.35rem; font-weight: 600; margin: 2.5rem 0 .75rem;
297
+ border-bottom: 1px solid #e0e0e0; padding-bottom: .3rem; scroll-margin-top: 1rem;
298
+ }
299
+ h3 { font-size: 1.1rem; font-weight: 600; margin: 1.5rem 0 .5rem; }
300
+ h4 { font-size: 1rem; font-weight: 600; margin: 1rem 0 .35rem; }
301
+ p { margin: .65rem 0; }
302
+ strong { font-weight: 600; }
303
+ code {
304
+ font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
305
+ font-size: .875em; background: #f5f5f5; padding: .1em .3em; border-radius: 3px;
306
+ }
307
+ pre {
308
+ background: #f5f5f5; border: 1px solid #e0e0e0; border-radius: 5px;
309
+ padding: 1rem; overflow-x: auto; margin: 1rem 0; line-height: 1.45;
310
+ }
311
+ pre code { background: none; padding: 0; font-size: .875rem; }
312
+ pre.mmr-bll { background: #f0f5fb; border-left: 3px solid #2563eb; border-radius: 0 5px 5px 0; }
313
+ abbr.mmr-var {
314
+ text-decoration: none; border-bottom: 1px dotted #2563eb;
315
+ cursor: help; position: relative;
316
+ }
317
+ abbr.mmr-var:hover::after {
318
+ content: attr(title);
319
+ position: absolute; left: 0; top: 1.4em;
320
+ background: #1a1a1a; color: #fff;
321
+ padding: .2rem .5rem; border-radius: 4px;
322
+ font-size: .78rem; white-space: nowrap; z-index: 20;
323
+ pointer-events: none;
324
+ }
325
+ ul, ol { padding-left: 1.5rem; margin: .65rem 0; }
326
+ li { margin: .25rem 0; }
327
+ .mmr-toolbar { display: flex; gap: .5rem; flex-wrap: wrap; margin: 1.25rem 0 1.5rem; }
328
+ .mmr-btn {
329
+ background: #1a1a1a; color: #fff; border: none;
330
+ padding: .45rem 1rem; border-radius: 4px; cursor: pointer; font-size: .875rem;
331
+ }
332
+ .mmr-btn:hover { background: #333; }
333
+ .mmr-btn.sec { background: #fff; color: #1a1a1a; border: 1px solid #ccc; }
334
+ .mmr-btn.sec:hover { background: #f5f5f5; }
335
+ .mmr-toc {
336
+ background: #fafafa; border: 1px solid #e4e4e7;
337
+ border-radius: 6px; padding: 1.25rem 1.5rem; margin: 1rem 0 2rem;
338
+ }
339
+ .mmr-toc-title {
340
+ font-size: .8rem; font-weight: 600; text-transform: uppercase;
341
+ letter-spacing: .08em; color: #666; margin-bottom: .75rem;
342
+ }
343
+ .mmr-toc ul { list-style: none; padding: 0; margin: 0; }
344
+ .mmr-toc li { margin: .3rem 0; font-size: .9rem; }
345
+ table.mmr-table { border-collapse: collapse; width: 100%; margin: 1rem 0; font-size: .9rem; }
346
+ table.mmr-table th,
347
+ table.mmr-table td { border: 1px solid #e0e0e0; padding: .45rem .75rem; text-align: left; vertical-align: top; }
348
+ table.mmr-table th { background: #f5f5f5; font-weight: 600; }
349
+ table.mmr-table tr:nth-child(even) { background: #fafafa; }
350
+ .mmr-accordion {
351
+ background: #f5f5f5; border: 1px solid #e0e0e0; color: #1a1a1a;
352
+ cursor: pointer; padding: .875rem 1rem; width: 100%; border-radius: 5px;
353
+ text-align: left; font-size: .95rem; font-weight: 500;
354
+ margin: 1rem 0 0; transition: background .15s;
355
+ }
356
+ .mmr-accordion::before { content: "\25b6 "; font-size: .75em; opacity: .5; }
357
+ .mmr-accordion.active::before { content: "\25bc "; }
358
+ .mmr-accordion:hover { background: #ebebeb; }
359
+ .mmr-accordion.active { border-radius: 5px 5px 0 0; }
360
+ .mmr-panel {
361
+ display: none; overflow: hidden; padding: 1rem 1.25rem;
362
+ border: 1px solid #e0e0e0; border-top: none;
363
+ border-radius: 0 0 5px 5px; background: #fff;
364
+ }
365
+ .mmr-top { display: inline-block; margin-top: 1rem; font-size: .8rem; color: #666; }
366
+ .mmr-error { color: #dc2626; font-style: italic; }
367
+ @media print {
368
+ .mmr-toolbar, .mmr-top { display: none !important; }
369
+ .mmr-accordion {
370
+ cursor: default; background: transparent !important; border: none !important;
371
+ font-weight: bold; padding: 0;
372
+ }
373
+ .mmr-accordion::before { content: "" !important; }
374
+ .mmr-panel { display: block !important; border: none !important; padding: .5rem 0; }
375
+ .mmr-toc { page-break-after: always; }
376
+ pre { white-space: pre-wrap; word-break: break-all; }
377
+ }"""
378
+
379
+ _MMREPORT_JS = """\
380
+ function mmrToggleToc() {
381
+ var el = document.getElementById("mmr-toc-body");
382
+ el.style.display = (el.style.display === "none") ? "" : "none";
383
+ }
384
+ function mmrExpandAll() {
385
+ document.querySelectorAll(".mmr-accordion").forEach(function(a) {
386
+ a.classList.add("active");
387
+ a.nextElementSibling.style.display = "block";
388
+ });
389
+ }
390
+ function mmrCollapseAll() {
391
+ document.querySelectorAll(".mmr-accordion").forEach(function(a) {
392
+ a.classList.remove("active");
393
+ a.nextElementSibling.style.display = "none";
394
+ });
395
+ }
396
+ function mmrPrint() { mmrExpandAll(); setTimeout(function() { window.print(); }, 300); }
397
+ function mmrDownload(fn) {
398
+ var blob = new Blob([document.documentElement.outerHTML], {type: "text/html"});
399
+ var url = URL.createObjectURL(blob);
400
+ var a = document.createElement("a");
401
+ a.href = url; a.download = fn; a.click();
402
+ URL.revokeObjectURL(url);
403
+ }
404
+ document.addEventListener("DOMContentLoaded", function() {
405
+ document.querySelectorAll(".mmr-accordion").forEach(function(btn) {
406
+ btn.addEventListener("click", function() {
407
+ this.classList.toggle("active");
408
+ var p = this.nextElementSibling;
409
+ p.style.display = (p.style.display === "block") ? "none" : "block";
410
+ });
411
+ });
412
+ function mmrOpenForHash(hash) {
413
+ if (!hash || hash === "#top") return;
414
+ var el = document.querySelector(hash);
415
+ if (el && el.classList.contains("mmr-accordion")) {
416
+ el.classList.add("active");
417
+ var p = el.nextElementSibling;
418
+ if (p) p.style.display = "block";
419
+ setTimeout(function() { el.scrollIntoView({behavior: "smooth", block: "start"}); }, 60);
420
+ }
421
+ }
422
+ mmrOpenForHash(window.location.hash);
423
+ window.addEventListener("hashchange", function() { mmrOpenForHash(window.location.hash); });
424
+ });"""
425
+
426
+
427
+ def _mmreport_build_html(title: str, toc: list, body: str, filename: str) -> str:
428
+ """Assemble the complete, self-contained HTML document."""
429
+ esc = _mmr_html.escape
430
+ toc_items = '\n'.join(
431
+ f' <li><a href="#{esc(a)}">{esc(t)}</a></li>'
432
+ for a, t in toc
433
+ )
434
+ return (
435
+ '<!DOCTYPE html>\n'
436
+ '<html lang="en">\n'
437
+ '<head>\n'
438
+ '<meta charset="utf-8">\n'
439
+ '<meta name="viewport" content="width=device-width, initial-scale=1">\n'
440
+ f'<title>{esc(title)}</title>\n'
441
+ '<style>\n'
442
+ + _MMREPORT_CSS
443
+ + '\n</style>\n'
444
+ '<script>\n'
445
+ + _MMREPORT_JS
446
+ + '\n</script>\n'
447
+ '</head>\n'
448
+ f'<body id="top">\n'
449
+ f'<h1>{esc(title)}</h1>\n'
450
+ '<div class="mmr-toolbar">\n'
451
+ ' <button class="mmr-btn" onclick="mmrToggleToc()">Contents</button>\n'
452
+ ' <button class="mmr-btn" onclick="mmrExpandAll()">Expand all</button>\n'
453
+ ' <button class="mmr-btn" onclick="mmrCollapseAll()">Collapse all</button>\n'
454
+ ' <button class="mmr-btn sec" onclick="mmrPrint()">Print</button>\n'
455
+ f' <button class="mmr-btn sec" onclick="mmrDownload(\'{esc(filename)}\')">'
456
+ 'Download</button>\n'
457
+ '</div>\n'
458
+ '<nav class="mmr-toc">\n'
459
+ ' <div class="mmr-toc-title">Contents</div>\n'
460
+ ' <div id="mmr-toc-body">\n'
461
+ ' <ul>\n'
462
+ + toc_items
463
+ + '\n </ul>\n'
464
+ ' </div>\n'
465
+ '</nav>\n'
466
+ '<main>\n'
467
+ + body
468
+ + '\n</main>\n'
469
+ '</body>\n'
470
+ '</html>'
471
+ )
472
+
473
+
474
+ # ---------------------------------------------------------------------------
475
+ # Public class
476
+ # ---------------------------------------------------------------------------
477
+
478
+ @dataclass
479
+ class MakeModelReport:
480
+ """Self-contained HTML report for a Makemodel or Listmodels instance.
481
+
482
+ Renders ``original_statements`` markdown as HTML, builds a collapsible
483
+ Table of Contents from ``##`` headings, and inserts a collapsible
484
+ estimation panel immediately after each estimated equation line.
485
+
486
+ Parameters
487
+ ----------
488
+ makemodel_obj : Makemodel or Listmodels
489
+ Source model(s) to document.
490
+ title : str, optional
491
+ Report title. Defaults to ``modelname`` or ``"Model Report"``.
492
+ plot_format : {"svg", "png"}, default ``"svg"``
493
+ Embedded plot format.
494
+ report_all : bool, default ``False``
495
+ Include identity equations as minimal panels alongside estimated ones.
496
+
497
+ Examples
498
+ --------
499
+ >>> from modelhtml import MakeModelReport
500
+ >>> rpt = MakeModelReport(mm, title="Consumption model")
501
+ >>> rpt.save() # writes html/consumption-model_report.html
502
+ >>> rpt.save(open_file=True) # same, opens in browser
503
+ >>> rpt.show() # open a temp copy in the browser
504
+ """
505
+
506
+ makemodel_obj: Any
507
+ title: str = ""
508
+ plot_format: str = "svg"
509
+ report_all: bool = False
510
+
511
+ def _effective_title(self) -> str:
512
+ if self.title:
513
+ return self.title
514
+ return getattr(self.makemodel_obj, 'modelname', '') or "Model Report"
515
+
516
+ def _iter_sources(self):
517
+ obj = self.makemodel_obj
518
+ if hasattr(obj, 'makemodels'): # Listmodels
519
+ yield from obj.makemodels
520
+ else: # Makemodel or compatible duck type
521
+ yield obj
522
+
523
+ def _build_html(self) -> str:
524
+ title = self._effective_title()
525
+ filename = f'{_mmr_slugify(title)}_report.html'
526
+ all_toc: List[tuple] = []
527
+ all_parts: List[str] = []
528
+ is_multi = hasattr(self.makemodel_obj, 'makemodels')
529
+
530
+ for mex in self._iter_sources():
531
+ sub_name = getattr(mex, 'modelname', '') or ''
532
+ md_text = getattr(mex, 'original_statements', '') or ''
533
+ est_recs = list(getattr(mex, 'estimation_records', []))
534
+
535
+ if is_multi and sub_name:
536
+ anchor = _mmr_slugify(sub_name)
537
+ all_toc.append((anchor, sub_name))
538
+ all_parts.append(
539
+ f'<h2 id="{_mmr_html.escape(anchor)}">'
540
+ f'{_mmr_html.escape(sub_name)}</h2>'
541
+ )
542
+
543
+ var_desc = getattr(mex, 'var_description', None) or {}
544
+ body_html, toc_entries = _mmr_md_to_html(
545
+ md_text, est_recs, self.plot_format, self.report_all, var_desc
546
+ )
547
+ all_toc.extend(toc_entries)
548
+ all_parts.append(body_html)
549
+
550
+ return _mmreport_build_html(
551
+ title=title,
552
+ toc=all_toc,
553
+ body='\n'.join(all_parts),
554
+ filename=filename,
555
+ )
556
+
557
+ def save(
558
+ self,
559
+ path: str = "html",
560
+ filename: str = "",
561
+ open_file: bool = False,
562
+ ) -> str:
563
+ """Write the HTML report to *path/filename*.
564
+
565
+ Parameters
566
+ ----------
567
+ path : str, default ``"html"``
568
+ Output directory (created if needed).
569
+ filename : str, optional
570
+ File name inside *path*. Derived from the title when omitted.
571
+ open_file : bool, default ``False``
572
+ Open the saved file in the default browser after writing.
573
+
574
+ Returns
575
+ -------
576
+ str
577
+ Absolute path of the saved file.
578
+ """
579
+ from pathlib import Path
580
+ import webbrowser
581
+ html_text = self._build_html()
582
+ out_dir = Path(path)
583
+ out_dir.mkdir(parents=True, exist_ok=True)
584
+ if not filename:
585
+ filename = f'{_mmr_slugify(self._effective_title())}_report.html'
586
+ full = out_dir / filename
587
+ full.write_text(html_text, encoding='utf-8')
588
+ print(f"✔ Report saved → {full.resolve()}")
589
+ if open_file:
590
+ webbrowser.open(f"file://{full.resolve()}")
591
+ return str(full)
592
+
593
+ def show(self) -> None:
594
+ """Write to a temp file and open in the default browser."""
595
+ import tempfile
596
+ import webbrowser
597
+ from pathlib import Path
598
+ with tempfile.NamedTemporaryFile(
599
+ mode='w', suffix='.html', encoding='utf-8', delete=False
600
+ ) as fh:
601
+ fh.write(self._build_html())
602
+ webbrowser.open(f"file://{Path(fh.name).resolve()}")
603
+
604
+ def _repr_html_(self) -> str:
605
+ """Jupyter rich-display: embeds the full HTML page."""
606
+ return self._build_html()