staticdash 2025.27__tar.gz → 2025.29__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.
- {staticdash-2025.27 → staticdash-2025.29}/PKG-INFO +1 -1
- {staticdash-2025.27 → staticdash-2025.29}/pyproject.toml +1 -1
- {staticdash-2025.27 → staticdash-2025.29}/staticdash/assets/css/style.css +22 -1
- staticdash-2025.29/staticdash/dashboard.py +388 -0
- {staticdash-2025.27 → staticdash-2025.29}/staticdash.egg-info/PKG-INFO +1 -1
- staticdash-2025.27/staticdash/dashboard.py +0 -654
- {staticdash-2025.27 → staticdash-2025.29}/README.md +0 -0
- {staticdash-2025.27 → staticdash-2025.29}/setup.cfg +0 -0
- {staticdash-2025.27 → staticdash-2025.29}/staticdash/__init__.py +0 -0
- {staticdash-2025.27 → staticdash-2025.29}/staticdash/assets/js/script.js +0 -0
- {staticdash-2025.27 → staticdash-2025.29}/staticdash.egg-info/SOURCES.txt +0 -0
- {staticdash-2025.27 → staticdash-2025.29}/staticdash.egg-info/dependency_links.txt +0 -0
- {staticdash-2025.27 → staticdash-2025.29}/staticdash.egg-info/requires.txt +0 -0
- {staticdash-2025.27 → staticdash-2025.29}/staticdash.egg-info/top_level.txt +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "staticdash"
|
|
7
|
-
version = "2025.
|
|
7
|
+
version = "2025.29"
|
|
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,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(" ", " ").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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|