staticdash 2025.27__tar.gz → 2025.28__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: staticdash
3
- Version: 2025.27
3
+ Version: 2025.28
4
4
  Summary: A lightweight static HTML dashboard generator with Plotly and pandas support.
5
5
  Author-email: Brian Day <brian.day1@gmail.com>
6
6
  License: CC0-1.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "staticdash"
7
- version = "2025.27"
7
+ version = "2025.28"
8
8
  description = "A lightweight static HTML dashboard generator with Plotly and pandas support."
9
9
  authors = [
10
10
  { name = "Brian Day", email = "brian.day1@gmail.com" }
@@ -371,4 +371,25 @@ body.sidebar-animate .sidebar-arrow {
371
371
  background-color: #34495e;
372
372
  color: #fff;
373
373
  text-decoration: none;
374
- }
374
+ }
375
+
376
+ :root {
377
+ --sidebar-width: 240px; /* matches #sidebar width */
378
+ --content-padding-x: 20px; /* matches #content horizontal padding */
379
+ }
380
+
381
+ /* Pure-CSS triangle for sidebar arrow */
382
+ .sidebar-arrow {
383
+ width: 0;
384
+ height: 0;
385
+ border-top: 6px solid transparent;
386
+ border-bottom: 6px solid transparent;
387
+ border-left: 8px solid #bdc3c7; /* arrow color */
388
+ margin-right: 6px;
389
+ }
390
+
391
+ /* Rotate when group is open (you already have this rule; keep it) */
392
+ body.sidebar-animate .sidebar-group.open .sidebar-arrow {
393
+ transform: rotate(90deg);
394
+ transform-origin: 30% 50%;
395
+ }
@@ -0,0 +1,388 @@
1
+ import os
2
+ import shutil
3
+ import uuid
4
+ import pandas as pd
5
+ import plotly.graph_objects as go
6
+ from dominate import document
7
+ from dominate.tags import div, h1, h2, h3, h4, p, a, script, link, span
8
+ from dominate.util import raw as raw_util
9
+ import html
10
+ import io
11
+ import base64
12
+
13
+ class AbstractPage:
14
+ def __init__(self):
15
+ self.elements = []
16
+
17
+ def add_header(self, text, level=1, width=None):
18
+ if level not in (1, 2, 3, 4):
19
+ raise ValueError("Header level must be 1, 2, 3, or 4")
20
+ self.elements.append(("header", (text, level), width))
21
+
22
+ def add_text(self, text, width=None):
23
+ self.elements.append(("text", text, width))
24
+
25
+ def add_plot(self, plot, width=None):
26
+ self.elements.append(("plot", plot, width))
27
+
28
+ def add_table(self, df, table_id=None, sortable=True, width=None):
29
+ self.elements.append(("table", df, width))
30
+
31
+ def add_download(self, file_path, label=None, width=None):
32
+ if not os.path.isfile(file_path):
33
+ raise FileNotFoundError(f"File not found: {file_path}")
34
+ self.elements.append(("download", (file_path, label), width))
35
+
36
+ def add_minipage(self, minipage, width=None):
37
+ self.elements.append(("minipage", minipage, width))
38
+
39
+ def add_syntax(self, code, language="python", width=None):
40
+ self.elements.append(("syntax", (code, language), width))
41
+
42
+
43
+ class Page(AbstractPage):
44
+ def __init__(self, slug, title, page_width=None, marking=None):
45
+ super().__init__()
46
+ self.slug = slug
47
+ self.title = title
48
+ self.page_width = page_width
49
+ self.marking = marking # None => inherit dashboard; str => override
50
+ self.children = []
51
+
52
+ def add_subpage(self, page):
53
+ self.children.append(page)
54
+
55
+ def render(
56
+ self,
57
+ index,
58
+ downloads_dir=None,
59
+ relative_prefix="",
60
+ inherited_width=None,
61
+ inherited_marking=None,
62
+ inherited_distribution=None
63
+ ):
64
+ effective_width = self.page_width or inherited_width
65
+ effective_marking = self.marking if (self.marking is not None) else inherited_marking
66
+ effective_distribution = getattr(self, "distribution", None) or inherited_distribution
67
+
68
+ elements = []
69
+
70
+ # Only show bars if a marking is present (no default text at all).
71
+ if effective_marking:
72
+ # Center within the MAIN CONTENT AREA (exclude sidebar and #content padding):
73
+ # left = sidebar_width + content_padding_x
74
+ # width = viewport - sidebar_width - 2*content_padding_x
75
+ shared_pos = (
76
+ "position: fixed; "
77
+ "left: calc(var(--sidebar-width, 240px) + var(--content-padding-x, 20px)); "
78
+ "width: calc(100vw - var(--sidebar-width, 240px) - 2*var(--content-padding-x, 20px)); "
79
+ "text-align: center; "
80
+ "background-color: #f8f9fa; "
81
+ "padding: 10px; "
82
+ "z-index: 1000; "
83
+ "font-weight: normal;"
84
+ )
85
+
86
+ elements.append(div(
87
+ effective_marking,
88
+ cls="floating-header",
89
+ style=f"{shared_pos} top: 0;"
90
+ ))
91
+
92
+ footer_block = []
93
+ if effective_distribution:
94
+ footer_block.append(div(effective_distribution, style="margin-bottom: 4px; font-size: 10pt;"))
95
+ footer_block.append(div(effective_marking))
96
+
97
+ elements.append(div(
98
+ *footer_block,
99
+ cls="floating-footer",
100
+ style=f"{shared_pos} bottom: 0;"
101
+ ))
102
+
103
+ # Render elements
104
+ for kind, content, el_width in self.elements:
105
+ style = ""
106
+ outer_style = ""
107
+ if el_width is not None:
108
+ style = f"width: {el_width * 100}%;"
109
+ outer_style = "display: flex; justify-content: center; margin: 0 auto;"
110
+ elem = None
111
+ if kind == "text":
112
+ elem = p(content)
113
+ elif kind == "header":
114
+ text, level = content
115
+ header_tag = {1: h1, 2: h2, 3: h3, 4: h4}[level]
116
+ elem = header_tag(text)
117
+ elif kind == "plot":
118
+ fig = content
119
+ if hasattr(fig, "to_html"):
120
+ elem = div(raw_util(fig.to_html(full_html=False, include_plotlyjs='cdn', config={'responsive': True})))
121
+ else:
122
+ try:
123
+ buf = io.BytesIO()
124
+ fig.savefig(buf, format="png", bbox_inches="tight")
125
+ buf.seek(0)
126
+ img_base64 = base64.b64encode(buf.read()).decode("utf-8")
127
+ buf.close()
128
+ elem = div(
129
+ raw_util(f'<img src="data:image/png;base64,{img_base64}" style="max-width:100%;">'),
130
+ style="display: flex; justify-content: center; align-items: center;"
131
+ )
132
+ except Exception as e:
133
+ elem = div(f"Matplotlib figure could not be rendered: {e}")
134
+ elif kind == "table":
135
+ df = content
136
+ try:
137
+ html_table = df.to_html(classes="table-hover table-striped", index=False, border=0, table_id=f"table-{index}", escape=False)
138
+ elem = div(raw_util(html_table))
139
+ except Exception as e:
140
+ elem = div(f"Table could not be rendered: {e}")
141
+ elif kind == "download":
142
+ file_path, label = content
143
+ btn = a(label or os.path.basename(file_path), href=file_path, cls="download-button", download=True)
144
+ elem = div(btn)
145
+ elif kind == "syntax":
146
+ code, language = content
147
+ elem = div(
148
+ raw_util(
149
+ f'<pre class="syntax-block"><code class="language-{language}">{html.escape(code)}</code></pre>'
150
+ ),
151
+ cls="syntax-block"
152
+ )
153
+ elif kind == "minipage":
154
+ elem = content.render(
155
+ index,
156
+ downloads_dir=downloads_dir,
157
+ relative_prefix=relative_prefix,
158
+ inherited_width=effective_width,
159
+ inherited_marking=effective_marking,
160
+ inherited_distribution=effective_distribution
161
+ )
162
+ if el_width is not None:
163
+ elem = div(elem, style=style)
164
+ elem = div(elem, style=outer_style)
165
+ elements.append(elem)
166
+
167
+ # Expose --content-width if you need it later, but centering no longer depends on it
168
+ wrapper_style = (
169
+ f"max-width: {effective_width}px; "
170
+ "margin: 0 auto; width: 100%; "
171
+ "padding-top: 80px; padding-bottom: 80px; "
172
+ f"--content-width: {effective_width}px;"
173
+ )
174
+ wrapper = div(*elements, style=wrapper_style)
175
+ return [wrapper]
176
+
177
+
178
+ class MiniPage(AbstractPage):
179
+ def __init__(self, page_width=None):
180
+ super().__init__()
181
+ self.page_width = page_width
182
+
183
+ def render(self, index=None, downloads_dir=None, relative_prefix="", inherited_width=None, inherited_marking=None, inherited_distribution=None):
184
+ effective_width = self.page_width or inherited_width
185
+ row_div = div(cls="minipage-row", style=f"max-width: {effective_width}px; margin: 0 auto; width: 100%;")
186
+ for kind, content, el_width in self.elements:
187
+ style = ""
188
+ outer_style = ""
189
+ if el_width is not None:
190
+ style = f"width: {el_width * 100}%;"
191
+ outer_style = "display: flex; justify-content: center; margin: 0 auto;"
192
+ elem = None
193
+ if kind == "text":
194
+ elem = p(content)
195
+ elif kind == "header":
196
+ text, level = content
197
+ header_tag = {1: h1, 2: h2, 3: h3, 4: h4}[level]
198
+ elem = header_tag(text)
199
+ elif kind == "plot":
200
+ fig = content
201
+ if hasattr(fig, "to_html"):
202
+ elem = div(raw_util(fig.to_html(full_html=False, include_plotlyjs='cdn', config={'responsive': True})))
203
+ else:
204
+ try:
205
+ buf = io.BytesIO()
206
+ fig.savefig(buf, format="png", bbox_inches="tight")
207
+ buf.seek(0)
208
+ img_base64 = base64.b64encode(buf.read()).decode("utf-8")
209
+ buf.close()
210
+ elem = div(
211
+ raw_util(f'<img src="data:image/png;base64,{img_base64}" style="max-width:100%;">'),
212
+ style="display: flex; justify-content: center; align-items: center;"
213
+ )
214
+ except Exception as e:
215
+ elem = div(f"Matplotlib figure could not be rendered: {e}")
216
+ elif kind == "table":
217
+ df = content
218
+ html_table = df.to_html(classes="table-hover table-striped", index=False, border=0, table_id=f"table-{index}", escape=False)
219
+ elem = raw_util(html_table)
220
+ elif kind == "download":
221
+ file_path, label = content
222
+ btn = a(label or os.path.basename(file_path), href=file_path, cls="download-button", download=True)
223
+ elem = div(btn)
224
+ elif kind == "syntax":
225
+ code, language = content
226
+ elem = div(
227
+ raw_util(
228
+ f'<pre class="syntax-block"><code class="language-{language}">{html.escape(code)}</code></pre>'
229
+ ),
230
+ cls="syntax-block"
231
+ )
232
+ elif kind == "minipage":
233
+ elem = content.render(
234
+ index,
235
+ downloads_dir=downloads_dir,
236
+ relative_prefix=relative_prefix,
237
+ inherited_width=effective_width,
238
+ inherited_marking=inherited_marking,
239
+ inherited_distribution=inherited_distribution
240
+ )
241
+ if el_width is not None:
242
+ elem = div(elem, style=style)
243
+ elem = div(elem, style=outer_style)
244
+ cell = div(elem, cls="minipage-cell")
245
+ row_div += cell
246
+ return row_div
247
+
248
+
249
+ class Dashboard:
250
+ def __init__(self, title="Dashboard", page_width=900, marking=None, distribution=None):
251
+ self.title = title
252
+ self.pages = []
253
+ self.page_width = page_width
254
+ self.marking = marking # Dashboard-wide default (None => no marking)
255
+ self.distribution = distribution # Dashboard-wide distribution statement
256
+
257
+ def add_page(self, page):
258
+ self.pages.append(page)
259
+
260
+ def _render_sidebar(self, pages, prefix="", current_slug=None):
261
+ # Structure preserved for your JS/CSS:
262
+ # <div class="sidebar-group [open]">
263
+ # <a class="nav-link sidebar-parent" href="...">
264
+ # <span class="sidebar-arrow"></span>Title
265
+ # </a>
266
+ # <div class="sidebar-children"> ... </div>
267
+ # </div>
268
+ for page in pages:
269
+ page_href = f"{prefix}{page.slug}.html"
270
+ is_active = (page.slug == current_slug)
271
+
272
+ def has_active_child(pg):
273
+ return any(
274
+ child.slug == current_slug or has_active_child(child)
275
+ for child in getattr(pg, "children", [])
276
+ )
277
+
278
+ group_open = has_active_child(page)
279
+ link_classes = "nav-link"
280
+ if getattr(page, "children", []):
281
+ link_classes += " sidebar-parent"
282
+ if is_active:
283
+ link_classes += " active"
284
+
285
+ if getattr(page, "children", []):
286
+ group_cls = "sidebar-group"
287
+ if group_open or is_active:
288
+ group_cls += " open"
289
+ with div(cls=group_cls):
290
+ a([
291
+ span("", cls="sidebar-arrow"), # pure-CSS triangle (no Unicode)
292
+ page.title
293
+ ], cls=link_classes, href=page_href)
294
+ with div(cls="sidebar-children"):
295
+ self._render_sidebar(page.children, prefix, current_slug)
296
+ else:
297
+ a(page.title, cls=link_classes, href=page_href)
298
+
299
+ def publish(self, output_dir="output"):
300
+ output_dir = os.path.abspath(output_dir)
301
+ pages_dir = os.path.join(output_dir, "pages")
302
+ downloads_dir = os.path.join(output_dir, "downloads")
303
+ assets_src = os.path.join(os.path.dirname(__file__), "assets")
304
+ assets_dst = os.path.join(output_dir, "assets")
305
+
306
+ os.makedirs(pages_dir, exist_ok=True)
307
+ os.makedirs(downloads_dir, exist_ok=True)
308
+ shutil.copytree(assets_src, assets_dst, dirs_exist_ok=True)
309
+
310
+ def write_page(page):
311
+ doc = document(title=page.title)
312
+ effective_width = page.page_width or self.page_width or 900
313
+ with doc.head:
314
+ doc.head.add(link(rel="stylesheet", href="../assets/css/style.css"))
315
+ doc.head.add(script(type="text/javascript", src="../assets/js/script.js"))
316
+ doc.head.add(script(src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"))
317
+ doc.head.add(link(rel="stylesheet", href="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism-tomorrow.min.css"))
318
+ doc.head.add(script(src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/prism.min.js"))
319
+ doc.head.add(script(src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-python.min.js"))
320
+ doc.head.add(script(src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-javascript.min.js"))
321
+ # Defaults that match your CSS; override in CSS if they change
322
+ doc.head.add(raw_util("<style>:root{--sidebar-width:240px;--content-padding-x:20px;}</style>"))
323
+ doc.head.add(raw_util(f"<style>.content-inner {{ max-width: {effective_width}px !important; }}</style>"))
324
+ with doc:
325
+ with div(id="sidebar"):
326
+ a(self.title, href="../index.html", cls="sidebar-title")
327
+ self._render_sidebar(self.pages, prefix="", current_slug=page.slug)
328
+ with div(id="sidebar-footer"):
329
+ a("Produced by staticdash", href="https://pypi.org/project/staticdash/", target="_blank")
330
+ with div(id="content"):
331
+ with div(cls="content-inner"):
332
+ for el in page.render(
333
+ 0,
334
+ downloads_dir=downloads_dir,
335
+ relative_prefix="../",
336
+ inherited_width=self.page_width,
337
+ inherited_marking=self.marking,
338
+ inherited_distribution=self.distribution
339
+ ):
340
+ div(el)
341
+
342
+ with open(os.path.join(pages_dir, f"{page.slug}.html"), "w", encoding="utf-8") as f:
343
+ f.write(str(doc))
344
+
345
+ for child in getattr(page, "children", []):
346
+ write_page(child)
347
+
348
+ for page in self.pages:
349
+ write_page(page)
350
+
351
+ # Index page (first page)
352
+ index_doc = document(title=self.title)
353
+ effective_width = self.pages[0].page_width or self.page_width or 900
354
+ with index_doc.head:
355
+ index_doc.head.add(link(rel="stylesheet", href="assets/css/style.css"))
356
+ index_doc.head.add(script(type="text/javascript", src="assets/js/script.js"))
357
+ index_doc.head.add(script(src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"))
358
+ index_doc.head.add(link(rel="stylesheet", href="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism-tomorrow.min.css"))
359
+ index_doc.head.add(script(src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/prism.min.js"))
360
+ index_doc.head.add(script(src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-python.min.js"))
361
+ index_doc.head.add(script(src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-javascript.min.js"))
362
+ index_doc.head.add(script(src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-sql.min.js"))
363
+ index_doc.head.add(script(src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-markup.min.js"))
364
+ index_doc.head.add(script(src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-bash.min.js"))
365
+ index_doc.head.add(script(src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-json.min.js"))
366
+ index_doc.head.add(script(src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-c.min.js"))
367
+ index_doc.head.add(raw_util("<style>:root{--sidebar-width:240px;--content-padding-x:20px;}</style>"))
368
+ index_doc.head.add(raw_util(f"<style>.content-inner {{ max-width: {effective_width}px !important; }}</style>"))
369
+ with index_doc:
370
+ with div(id="sidebar"):
371
+ a(self.title, href="index.html", cls="sidebar-title")
372
+ self._render_sidebar(self.pages, prefix="pages/", current_slug=self.pages[0].slug)
373
+ with div(id="sidebar-footer"):
374
+ a("Produced by staticdash", href="https://pypi.org/project/staticdash/", target="_blank")
375
+ with div(id="content"):
376
+ with div(cls="content-inner"):
377
+ for el in self.pages[0].render(
378
+ 0,
379
+ downloads_dir=downloads_dir,
380
+ relative_prefix="",
381
+ inherited_width=self.page_width,
382
+ inherited_marking=self.marking,
383
+ inherited_distribution=self.distribution
384
+ ):
385
+ div(el)
386
+
387
+ with open(os.path.join(output_dir, "index.html"), "w", encoding="utf-8") as f:
388
+ f.write(str(index_doc))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: staticdash
3
- Version: 2025.27
3
+ Version: 2025.28
4
4
  Summary: A lightweight static HTML dashboard generator with Plotly and pandas support.
5
5
  Author-email: Brian Day <brian.day1@gmail.com>
6
6
  License: CC0-1.0
@@ -1,654 +0,0 @@
1
- import os
2
- import shutil
3
- import uuid
4
- import pandas as pd
5
- import plotly.graph_objects as go
6
- from dominate import document
7
- from dominate.tags import div, h1, h2, h3, h4, p, a, script, link, span
8
- from dominate.util import raw as raw_util
9
- import html
10
- from reportlab.lib.pagesizes import letter, A4
11
- from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, PageBreak, Table, TableStyle, Image
12
- from reportlab.platypus.tableofcontents import TableOfContents
13
- from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
14
- from reportlab.lib import colors
15
- from reportlab.lib.units import inch
16
- import io
17
- import tempfile
18
- import matplotlib.pyplot as plt
19
- import io, base64
20
-
21
- class AbstractPage:
22
- def __init__(self):
23
- self.elements = []
24
-
25
-
26
- def add_header(self, text, level=1, width=None):
27
- if level not in (1, 2, 3, 4):
28
- raise ValueError("Header level must be 1, 2, 3, or 4")
29
- self.elements.append(("header", (text, level), width))
30
-
31
- def add_text(self, text, width=None):
32
- self.elements.append(("text", text, width))
33
-
34
- def add_plot(self, plot, width=None):
35
- self.elements.append(("plot", plot, width))
36
-
37
- def add_table(self, df, table_id=None, sortable=True, width=None):
38
- self.elements.append(("table", df, width))
39
-
40
- def add_download(self, file_path, label=None, width=None):
41
- if not os.path.isfile(file_path):
42
- raise FileNotFoundError(f"File not found: {file_path}")
43
- self.elements.append(("download", (file_path, label), width))
44
-
45
- def add_minipage(self, minipage, width=None):
46
- self.elements.append(("minipage", minipage, width))
47
-
48
- def add_syntax(self, code, language="python", width=None):
49
- self.elements.append(("syntax", (code, language), width))
50
-
51
- class Page(AbstractPage):
52
- def __init__(self, slug, title, page_width=None, marking=None):
53
- super().__init__()
54
- self.slug = slug
55
- self.title = title
56
- self.page_width = page_width
57
- self.marking = marking # Page-specific marking
58
- self.children = []
59
- # self.add_header(title, level=1)
60
-
61
- def add_subpage(self, page):
62
- self.children.append(page)
63
-
64
- def render(self, index, downloads_dir=None, relative_prefix="", inherited_width=None):
65
- effective_width = self.page_width or inherited_width
66
- elements = []
67
-
68
- # # Add floating header and footer for marking
69
- # marking = self.marking or "Default Marking"
70
- # elements.append(div(
71
- # marking,
72
- # cls="floating-header",
73
- # style="position: fixed; top: 0; left: 50%; transform: translateX(-50%); width: auto; background-color: #f8f9fa; text-align: center; padding: 10px; z-index: 1000; font-weight: normal;"
74
- # ))
75
- # elements.append(div(
76
- # marking,
77
- # cls="floating-footer",
78
- # style="position: fixed; bottom: 0; left: 50%; transform: translateX(-50%); width: auto; background-color: #f8f9fa; text-align: center; padding: 10px; z-index: 1000; font-weight: normal;"
79
- # ))
80
-
81
- # Add floating header and footer with optional distribution
82
- marking = self.marking or "Default Marking"
83
- distribution = getattr(self, "distribution", None)
84
-
85
- elements.append(div(
86
- marking,
87
- cls="floating-header",
88
- style="position: fixed; top: 0; left: 50%; transform: translateX(-50%); width: auto; background-color: #f8f9fa; text-align: center; padding: 10px; z-index: 1000; font-weight: normal;"
89
- ))
90
-
91
- footer_block = []
92
- if distribution:
93
- footer_block.append(div(distribution, style="margin-bottom: 4px; font-size: 10pt;"))
94
- footer_block.append(div(marking))
95
-
96
- elements.append(div(
97
- *footer_block,
98
- cls="floating-footer",
99
- style="position: fixed; bottom: 0; left: 50%; transform: translateX(-50%); width: auto; background-color: #f8f9fa; text-align: center; padding: 10px; z-index: 1000; font-weight: normal;"
100
- ))
101
-
102
-
103
- for kind, content, el_width in self.elements:
104
- style = ""
105
- outer_style = ""
106
- if el_width is not None:
107
- style = f"width: {el_width * 100}%;"
108
- outer_style = "display: flex; justify-content: center; margin: 0 auto;"
109
- elem = None
110
- if kind == "text":
111
- elem = p(content)
112
- elif kind == "header":
113
- text, level = content
114
- header_tag = {1: h1, 2: h2, 3: h3, 4: h4}[level]
115
- elem = header_tag(text)
116
- elif kind == "plot":
117
- fig = content
118
- if hasattr(fig, "to_html"):
119
- elem = div(raw_util(fig.to_html(full_html=False, include_plotlyjs='cdn', config={'responsive': True})))
120
- else:
121
- try:
122
- buf = io.BytesIO()
123
- fig.savefig(buf, format="png", bbox_inches="tight")
124
- buf.seek(0)
125
- img_base64 = base64.b64encode(buf.read()).decode("utf-8")
126
- buf.close()
127
- elem = div(
128
- raw_util(f'<img src="data:image/png;base64,{img_base64}" style="max-width:100%;">'),
129
- style="display: flex; justify-content: center; align-items: center;"
130
- )
131
- except Exception as e:
132
- elem = div(f"Matplotlib figure could not be rendered: {e}")
133
- elif kind == "table":
134
- df = content
135
- try:
136
- html_table = df.to_html(classes="table-hover table-striped", index=False, border=0, table_id=f"table-{index}", escape=False)
137
- elem = div(raw_util(html_table))
138
- except Exception as e:
139
- elem = div(f"Table could not be rendered: {e}")
140
- elif kind == "download":
141
- file_path, label = content
142
- btn = a(label or os.path.basename(file_path), href=file_path, cls="download-button", download=True)
143
- elem = div(btn)
144
- elif kind == "syntax":
145
- code, language = content
146
- elem = div(
147
- raw_util(
148
- f'<pre class="syntax-block"><code class="language-{language}">{html.escape(code)}</code></pre>'
149
- ),
150
- cls="syntax-block"
151
- )
152
- elif kind == "minipage":
153
- elem = content.render(index, downloads_dir=downloads_dir, relative_prefix=relative_prefix, inherited_width=effective_width)
154
- if el_width is not None:
155
- elem = div(elem, style=style)
156
- elem = div(elem, style=outer_style)
157
- elements.append(elem)
158
-
159
- # Add padding to avoid overlap with header and footer
160
- wrapper = div(*elements, style=f"max-width: {effective_width}px; margin: 0 auto; width: 100%; padding-top: 80px; padding-bottom: 80px;")
161
- return [wrapper]
162
-
163
- class MiniPage(AbstractPage):
164
- def __init__(self, page_width=None):
165
- super().__init__()
166
- self.page_width = page_width
167
-
168
- def render(self, index=None, downloads_dir=None, relative_prefix="", inherited_width=None):
169
- effective_width = self.page_width or inherited_width
170
- row_div = div(cls="minipage-row", style=f"max-width: {effective_width}px; margin: 0 auto; width: 100%;")
171
- for kind, content, el_width in self.elements:
172
- style = ""
173
- outer_style = ""
174
- if el_width is not None:
175
- style = f"width: {el_width * 100}%;"
176
- outer_style = "display: flex; justify-content: center; margin: 0 auto;"
177
- elem = None
178
- if kind == "text":
179
- elem = p(content)
180
- elif kind == "header":
181
- text, level = content
182
- header_tag = {1: h1, 2: h2, 3: h3, 4: h4}[level]
183
- elem = header_tag(text)
184
- elif kind == "plot":
185
- fig = content
186
- # Plotly support (existing)
187
- if hasattr(fig, "to_html"):
188
- elem = div(raw_util(fig.to_html(full_html=False, include_plotlyjs='cdn', config={'responsive': True})))
189
- # Matplotlib support
190
- else:
191
- try:
192
- buf = io.BytesIO()
193
- fig.savefig(buf, format="png", bbox_inches="tight")
194
- buf.seek(0)
195
- img_base64 = base64.b64encode(buf.read()).decode("utf-8")
196
- buf.close()
197
- # Center the image using a div with inline styles
198
- elem = div(
199
- raw_util(f'<img src="data:image/png;base64,{img_base64}" style="max-width:100%;">'),
200
- style="display: flex; justify-content: center; align-items: center;"
201
- )
202
- except Exception as e:
203
- elem = div(f"Matplotlib figure could not be rendered: {e}")
204
- elif kind == "table":
205
- df = content
206
- html_table = df.to_html(classes="table-hover table-striped", index=False, border=0, table_id=f"table-{index}", escape=False)
207
- elem = raw_util(html_table)
208
- elif kind == "download":
209
- file_path, label = content
210
- btn = a(label or os.path.basename(file_path), href=file_path, cls="download-button", download=True)
211
- elem = div(btn)
212
- elif kind == "syntax":
213
- code, language = content
214
- elem = div(
215
- raw_util(
216
- f'<pre class="syntax-block"><code class="language-{language}">{html.escape(code)}</code></pre>'
217
- ),
218
- cls="syntax-block"
219
- )
220
- elif kind == "minipage":
221
- elem = content.render(index, downloads_dir=downloads_dir, relative_prefix=relative_prefix, inherited_width=effective_width)
222
- if el_width is not None:
223
- elem = div(elem, style=style)
224
- elem = div(elem, style=outer_style)
225
- cell = div(elem, cls="minipage-cell")
226
- row_div += cell
227
- return row_div
228
-
229
- class Dashboard:
230
- def __init__(self, title="Dashboard", page_width=900, marking=None, distribution=None):
231
- self.title = title
232
- self.pages = []
233
- self.page_width = page_width
234
- self.marking = marking # Dashboard-wide marking
235
- self.distribution = distribution # NEW: Distribution statement
236
-
237
- def add_page(self, page):
238
- self.pages.append(page)
239
-
240
- # def _track_outline(self, flowable):
241
- # """
242
- # Hook for collecting TOC entries and setting bookmarks.
243
- # """
244
- # from reportlab.platypus import Paragraph
245
- # if isinstance(flowable, Paragraph):
246
- # text = flowable.getPlainText()
247
- # style_name = flowable.style.name
248
- # if style_name.startswith("Heading"):
249
- # try:
250
- # level = int(style_name.replace("Heading", ""))
251
- # except ValueError:
252
- # return
253
- # key = f"bookmark_{uuid.uuid4().hex}"
254
- # flowable.canv.bookmarkPage(key)
255
- # flowable.canv.addOutlineEntry(text, key, level=level - 1, closed=False)
256
- # flowable._bookmarkName = key
257
-
258
-
259
- def _track_outline(self, canvas, doc):
260
- if hasattr(doc, '_last_heading'):
261
- level, text = doc._last_heading
262
- key = f"bookmark_{uuid.uuid4().hex}"
263
- canvas.bookmarkPage(key)
264
- canvas.addOutlineEntry(text, key, level=level - 1, closed=False)
265
- del doc._last_heading
266
-
267
-
268
- def _render_sidebar(self, pages, prefix="", current_slug=None):
269
- for page in pages:
270
- page_href = f"{prefix}{page.slug}.html"
271
- is_active = (page.slug == current_slug)
272
- def has_active_child(page):
273
- return any(
274
- child.slug == current_slug or has_active_child(child)
275
- for child in getattr(page, "children", [])
276
- )
277
- group_open = has_active_child(page)
278
- link_classes = "nav-link"
279
- if getattr(page, "children", []):
280
- link_classes += " sidebar-parent"
281
- if is_active:
282
- link_classes += " active"
283
- if getattr(page, "children", []):
284
- group_cls = "sidebar-group"
285
- if group_open or is_active:
286
- group_cls += " open"
287
- with div(cls=group_cls):
288
- a([
289
- span("▶", cls="sidebar-arrow"),
290
- page.title
291
- ], cls=link_classes, href=page_href)
292
- with div(cls="sidebar-children"):
293
- self._render_sidebar(page.children, prefix, current_slug)
294
- else:
295
- a(page.title, cls=link_classes, href=page_href)
296
-
297
- def publish(self, output_dir="output"):
298
- output_dir = os.path.abspath(output_dir)
299
- pages_dir = os.path.join(output_dir, "pages")
300
- downloads_dir = os.path.join(output_dir, "downloads")
301
- assets_src = os.path.join(os.path.dirname(__file__), "assets")
302
- assets_dst = os.path.join(output_dir, "assets")
303
-
304
- os.makedirs(pages_dir, exist_ok=True)
305
- os.makedirs(downloads_dir, exist_ok=True)
306
- shutil.copytree(assets_src, assets_dst, dirs_exist_ok=True)
307
-
308
- def write_page(page):
309
- doc = document(title=page.title)
310
- effective_width = page.page_width or self.page_width or 900
311
- with doc.head:
312
- doc.head.add(link(rel="stylesheet", href="../assets/css/style.css"))
313
- doc.head.add(script(type="text/javascript", src="../assets/js/script.js"))
314
- doc.head.add(script(src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"))
315
- doc.head.add(link(rel="stylesheet", href="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism-tomorrow.min.css"))
316
- doc.head.add(script(src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/prism.min.js"))
317
- doc.head.add(script(src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-python.min.js"))
318
- doc.head.add(script(src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-javascript.min.js"))
319
- # Inject dynamic page width
320
- doc.head.add(raw_util(f"<style>.content-inner {{ max-width: {effective_width}px !important; }}</style>"))
321
- with doc:
322
- with div(id="sidebar"):
323
- a(self.title, href="../index.html", cls="sidebar-title")
324
- self._render_sidebar(self.pages, prefix="", current_slug=page.slug)
325
- with div(id="sidebar-footer"):
326
- a("Produced by staticdash", href="https://pypi.org/project/staticdash/", target="_blank")
327
- with div(id="content"):
328
- with div(cls="content-inner"):
329
- for el in page.render(0, downloads_dir=downloads_dir, relative_prefix="../"):
330
- div(el)
331
- with open(os.path.join(pages_dir, f"{page.slug}.html"), "w") as f:
332
- f.write(str(doc))
333
- for child in getattr(page, "children", []):
334
- write_page(child)
335
-
336
- for page in self.pages:
337
- write_page(page)
338
-
339
- # Index page
340
- index_doc = document(title=self.title)
341
- effective_width = self.pages[0].page_width or self.page_width or 900
342
- with index_doc.head:
343
- index_doc.head.add(link(rel="stylesheet", href="assets/css/style.css"))
344
- index_doc.head.add(script(type="text/javascript", src="assets/js/script.js"))
345
- index_doc.head.add(script(src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"))
346
- index_doc.head.add(link(rel="stylesheet", href="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism-tomorrow.min.css"))
347
- index_doc.head.add(script(src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/prism.min.js"))
348
- index_doc.head.add(script(src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-python.min.js"))
349
- index_doc.head.add(script(src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-javascript.min.js"))
350
- index_doc.head.add(script(src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-sql.min.js"))
351
- index_doc.head.add(script(src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-markup.min.js"))
352
- index_doc.head.add(script(src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-bash.min.js"))
353
- index_doc.head.add(script(src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-json.min.js"))
354
- index_doc.head.add(script(src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-c.min.js"))
355
- # Inject dynamic page width
356
- index_doc.head.add(raw_util(f"<style>.content-inner {{ max-width: {effective_width}px !important; }}</style>"))
357
- with index_doc:
358
- with div(id="sidebar"):
359
- a(self.title, href="index.html", cls="sidebar-title")
360
- self._render_sidebar(self.pages, prefix="pages/", current_slug=self.pages[0].slug)
361
- with div(id="sidebar-footer"):
362
- a("Produced by staticdash", href="https://pypi.org/project/staticdash/", target="_blank")
363
- with div(id="content"):
364
- with div(cls="content-inner"):
365
- for el in self.pages[0].render(0, downloads_dir=downloads_dir, relative_prefix=""):
366
- div(el)
367
-
368
- with open(os.path.join(output_dir, "index.html"), "w") as f:
369
- f.write(str(index_doc))
370
-
371
- def publish_pdf(self, output_path="dashboard_report.pdf", pagesize="A4", include_toc=True, include_title_page=False, author=None, affiliation=None, title_page_marking=None):
372
- from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, PageBreak, Table, TableStyle, Image
373
- from reportlab.platypus.tableofcontents import TableOfContents
374
- from reportlab.lib.pagesizes import A4, letter
375
- from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
376
- from reportlab.lib import colors
377
- from reportlab.lib.units import inch
378
- from datetime import datetime
379
- import tempfile
380
- import io
381
- import os
382
- import plotly.io as pio
383
-
384
- pio.kaleido.scope.default_format = "png"
385
-
386
- page_size = A4 if pagesize.upper() == "A4" else letter
387
- styles = getSampleStyleSheet()
388
- normal_style = styles['Normal']
389
-
390
- styles['Heading1'].fontSize = 18
391
- styles['Heading1'].spaceAfter = 12
392
- styles['Heading1'].spaceBefore = 18
393
- styles['Heading1'].fontName = 'Helvetica-Bold'
394
-
395
- styles['Heading2'].fontSize = 14
396
- styles['Heading2'].spaceAfter = 8
397
- styles['Heading2'].spaceBefore = 12
398
- styles['Heading2'].fontName = 'Helvetica-Bold'
399
-
400
- if 'CodeBlock' not in styles:
401
- styles.add(ParagraphStyle(
402
- name='CodeBlock',
403
- fontName='Courier',
404
- fontSize=9,
405
- leading=12,
406
- backColor=colors.whitesmoke,
407
- leftIndent=12,
408
- rightIndent=12,
409
- spaceAfter=8,
410
- borderPadding=4
411
- ))
412
-
413
- story = []
414
-
415
- class MyDocTemplate(SimpleDocTemplate):
416
- def __init__(self, *args, **kwargs):
417
- self.outline_entries = []
418
- self._outline_idx = 0
419
- super().__init__(*args, **kwargs)
420
-
421
-
422
- def afterFlowable(self, flowable):
423
- from reportlab.platypus import Paragraph
424
- if isinstance(flowable, Paragraph):
425
- style_name = flowable.style.name
426
- if style_name.startswith('Heading'):
427
- try:
428
- level = int(style_name.replace("Heading", ""))
429
- except ValueError:
430
- return # Not a valid heading style
431
-
432
- # Convert to outline level (0 = H1, 1 = H2, etc.)
433
- outline_level = level - 1
434
-
435
- # Clamp max to 2 for PDF outline safety
436
- outline_level = max(0, min(outline_level, 2))
437
-
438
- # Prevent skipping levels: ensure intermediates exist
439
- # Track previous levels (add this as a class attribute if needed)
440
- if not hasattr(self, "_last_outline_level"):
441
- self._last_outline_level = -1
442
-
443
- if outline_level > self._last_outline_level + 1:
444
- outline_level = self._last_outline_level + 1 # prevent jump
445
-
446
- self._last_outline_level = outline_level
447
-
448
- text = flowable.getPlainText()
449
- key = 'heading_%s' % self.seq.nextf('heading')
450
- self.canv.bookmarkPage(key)
451
- self.canv.addOutlineEntry(text, key, level=outline_level, closed=False)
452
-
453
-
454
- self.notify('TOCEntry', (outline_level, text, self.page))
455
-
456
-
457
- def add_marking(canvas, doc, marking):
458
- from reportlab.pdfbase.pdfmetrics import stringWidth
459
-
460
- distribution = self.distribution
461
- canvas.saveState()
462
- canvas.setFont("Helvetica", 10)
463
- width, height = doc.pagesize
464
-
465
- line_height = 12
466
- margin_y = 36
467
-
468
- # Top of page marking
469
- if marking:
470
- top_width = stringWidth(marking, "Helvetica", 10)
471
- x_top = (width - top_width) / 2
472
- canvas.drawString(x_top, height - margin_y, marking)
473
-
474
- # Bottom of page — prepare to stack upward
475
- y = margin_y
476
-
477
- # First: bottom marking
478
- if marking:
479
- bot_width = stringWidth(marking, "Helvetica", 10)
480
- x_bot = (width - bot_width) / 2
481
- canvas.drawString(x_bot, y, marking)
482
- y += line_height # make room above
483
-
484
- # Then: bottom distribution (above marking)
485
- if distribution:
486
- max_width = width - 144 # ~1" margins
487
- words = distribution.split()
488
- lines = []
489
- current_line = []
490
-
491
- for word in words:
492
- test_line = " ".join(current_line + [word])
493
- if stringWidth(test_line, "Helvetica", 10) <= max_width:
494
- current_line.append(word)
495
- else:
496
- lines.append(" ".join(current_line))
497
- current_line = [word]
498
-
499
- if current_line:
500
- lines.append(" ".join(current_line))
501
-
502
- # Draw top-down (above marking)
503
- for line in reversed(lines):
504
- line_width = stringWidth(line, "Helvetica", 10)
505
- x = (width - line_width) / 2
506
- y += line_height
507
- canvas.drawString(x, y, line)
508
-
509
- canvas.restoreState()
510
-
511
-
512
- if include_title_page:
513
- story.append(Spacer(1, 120))
514
- story.append(Paragraph(f"<b>{self.title}</b>", styles['Title']))
515
- story.append(Spacer(1, 48))
516
- lines = []
517
- if author:
518
- lines.append(str(author))
519
- if affiliation:
520
- lines.append(str(affiliation))
521
- lines.append(datetime.now().strftime('%B %d, %Y'))
522
- story.append(Paragraph("<para align='center'>" + "<br/>".join(lines) + "</para>", normal_style))
523
- story.append(PageBreak())
524
-
525
- if include_toc:
526
- toc = TableOfContents()
527
- toc.levelStyles = [
528
- ParagraphStyle(name='TOCHeading1', fontSize=14, leftIndent=20, firstLineIndent=-20, spaceBefore=10, leading=16),
529
- ParagraphStyle(name='TOCHeading2', fontSize=12, leftIndent=40, firstLineIndent=-20, spaceBefore=5, leading=12),
530
- ]
531
- story.append(Paragraph("Table of Contents", styles["Title"]))
532
- story.append(toc)
533
- story.append(PageBreak())
534
-
535
- def render_page(page, level=0, sec_prefix=[]):
536
- # Generate section numbering like "1", "1.2", etc.
537
- section_number = ".".join(map(str, sec_prefix))
538
- heading_text = f"{section_number} {page.title}"
539
-
540
- heading_style = styles.get(f'Heading{min(level + 1, 4)}', styles['Heading4'])
541
- story.append(Paragraph(heading_text, heading_style))
542
- story.append(Spacer(1, 12))
543
-
544
-
545
- # Remember where we started
546
- content_start = len(story)
547
-
548
-
549
- for kind, content, _ in page.elements:
550
- try:
551
- if kind == "text":
552
- story.append(Paragraph(content, normal_style))
553
- story.append(Spacer(1, 8))
554
-
555
- elif kind == "header":
556
- text, lvl = content
557
- safe_lvl = max(1, min(lvl + 1, 4))
558
- style = styles[f'Heading{safe_lvl}']
559
- story.append(Paragraph(text, style))
560
- story.append(Spacer(1, 8))
561
-
562
- elif kind == "table":
563
- df = content
564
- data = [df.columns.tolist()] + df.values.tolist()
565
- t = Table(data, repeatRows=1)
566
- t.setStyle(TableStyle([
567
- ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor("#222C36")),
568
- ('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke),
569
- ('ALIGN', (0, 0), (-1, -1), 'LEFT'),
570
- ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
571
- ('FONTSIZE', (0, 0), (-1, 0), 11),
572
- ('BOTTOMPADDING', (0, 0), (-1, 0), 10),
573
- ('TOPPADDING', (0, 0), (-1, 0), 10),
574
- ('BACKGROUND', (0, 1), (-1, -1), colors.whitesmoke),
575
- ('GRID', (0, 0), (-1, -1), 0.5, colors.HexColor("#B0B8C1")),
576
- ('FONTNAME', (0, 1), (-1, -1), 'Helvetica'),
577
- ('FONTSIZE', (0, 1), (-1, -1), 10),
578
- ('LEFTPADDING', (0, 0), (-1, -1), 6),
579
- ('RIGHTPADDING', (0, 0), (-1, -1), 6),
580
- ('TOPPADDING', (0, 1), (-1, -1), 6),
581
- ('BOTTOMPADDING', (0, 1), (-1, -1), 6),
582
- ]))
583
- story.append(t)
584
- story.append(Spacer(1, 12))
585
-
586
- elif kind == "plot":
587
- fig = content
588
- buf = io.BytesIO()
589
- if hasattr(fig, "savefig"):
590
- fig.savefig(buf, format="png", bbox_inches="tight", dpi=600)
591
- else:
592
- fig.write_image(buf, format="png", width=800, height=600, scale=3)
593
- buf.seek(0)
594
- story.append(Image(buf, width=6 * inch, height=4.5 * inch))
595
- story.append(Spacer(1, 12))
596
-
597
- elif kind == "syntax":
598
- code, language = content
599
- from html import escape
600
- story.append(Paragraph(f"<b>Code ({language}):</b>", normal_style))
601
- story.append(Spacer(1, 4))
602
- code_html = escape(code).replace(" ", "&nbsp;").replace("\n", "<br/>")
603
- story.append(Paragraph(f"<font face='Courier'>{code_html}</font>", styles['CodeBlock']))
604
- story.append(Spacer(1, 12))
605
-
606
- elif kind == "minipage":
607
- render_page(content, level=level + 1, sec_prefix=sec_prefix)
608
-
609
- except Exception as e:
610
- story.append(Paragraph(f"Error rendering element: {e}", normal_style))
611
-
612
-
613
- just_broke = False
614
-
615
- for i, child in enumerate(page.children):
616
- if i > 0 and not just_broke:
617
- story.append(PageBreak())
618
-
619
- before = len(story)
620
- render_page(child, level=level + 1, sec_prefix=sec_prefix + [i + 1])
621
- after = len(story)
622
-
623
- # Determine if child added a PageBreak
624
- just_broke = isinstance(story[-1], PageBreak) if after > before else False
625
-
626
-
627
- # Determine if anything meaningful was added
628
- def has_meaningful_content(start_idx):
629
- for elem in story[start_idx:]:
630
- if isinstance(elem, (Paragraph, Table, Image)):
631
- return True
632
- return False
633
-
634
- if not page.children and has_meaningful_content(content_start):
635
- story.append(PageBreak())
636
-
637
-
638
-
639
- for i, page in enumerate(self.pages):
640
- render_page(page, level=0, sec_prefix=[i + 1])
641
-
642
-
643
-
644
- doc = MyDocTemplate(
645
- output_path,
646
- pagesize=page_size,
647
- rightMargin=72, leftMargin=72, topMargin=72, bottomMargin=72,
648
- )
649
-
650
- doc.multiBuild(
651
- story,
652
- onFirstPage=lambda canvas, doc: add_marking(canvas, doc, title_page_marking),
653
- onLaterPages=lambda canvas, doc: add_marking(canvas, doc, self.marking)
654
- )
File without changes
File without changes