staticdash 2025.20__tar.gz → 2025.22__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.20
3
+ Version: 2025.22
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.20"
7
+ version = "2025.22"
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" }
@@ -56,7 +56,7 @@ class Page(AbstractPage):
56
56
  self.page_width = page_width
57
57
  self.marking = marking # Page-specific marking
58
58
  self.children = []
59
- self.add_header(title, level=1)
59
+ # self.add_header(title, level=1)
60
60
 
61
61
  def add_subpage(self, page):
62
62
  self.children.append(page)
@@ -345,158 +345,225 @@ class Dashboard:
345
345
  with open(os.path.join(output_dir, "index.html"), "w") as f:
346
346
  f.write(str(index_doc))
347
347
 
348
- def publish_pdf(self, output_path="dashboard_report.pdf", pagesize="A4", include_title_page=False, title_page_marking=None, author=None, affiliation=None):
349
- from reportlab.platypus import SimpleDocTemplate, Spacer, Paragraph, PageBreak, Image
350
- from reportlab.lib.pagesizes import letter, A4
351
- from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
352
- from datetime import datetime
353
- import io
354
-
355
- page_size = A4 if pagesize == "A4" else letter
356
- styles = getSampleStyleSheet()
357
- story = []
358
-
359
- # Add title page
360
- if include_title_page:
361
- story.append(Spacer(1, 120))
362
- story.append(Paragraph(f"<b>{self.title}</b>", styles['Title']))
363
- story.append(Spacer(1, 48))
364
- if author:
365
- story.append(Paragraph(f"<para align='center'>{author}</para>", styles['Normal']))
366
- if affiliation:
367
- story.append(Paragraph(f"<para align='center'>{affiliation}</para>", styles['Normal']))
368
- current_date = datetime.now().strftime("%B %d, %Y")
369
- story.append(Paragraph(f"<para align='center'>{current_date}</para>", styles['Normal']))
370
- story.append(PageBreak())
371
-
372
- # Add Table of Contents
348
+ 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):
349
+ from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, PageBreak, Table, TableStyle, Image
350
+ from reportlab.platypus.tableofcontents import TableOfContents
351
+ from reportlab.lib.pagesizes import A4, letter
352
+ from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
353
+ from reportlab.lib import colors
354
+ from reportlab.lib.units import inch
355
+ from datetime import datetime
356
+ import tempfile
357
+ import io
358
+ import os
359
+ import plotly.io as pio
360
+
361
+ pio.kaleido.scope.default_format = "png"
362
+
363
+ page_size = A4 if pagesize.upper() == "A4" else letter
364
+ styles = getSampleStyleSheet()
365
+ normal_style = styles['Normal']
366
+
367
+ styles['Heading1'].fontSize = 18
368
+ styles['Heading1'].spaceAfter = 12
369
+ styles['Heading1'].spaceBefore = 18
370
+ styles['Heading1'].fontName = 'Helvetica-Bold'
371
+
372
+ styles['Heading2'].fontSize = 14
373
+ styles['Heading2'].spaceAfter = 8
374
+ styles['Heading2'].spaceBefore = 12
375
+ styles['Heading2'].fontName = 'Helvetica-Bold'
376
+
377
+ if 'CodeBlock' not in styles:
378
+ styles.add(ParagraphStyle(
379
+ name='CodeBlock',
380
+ fontName='Courier',
381
+ fontSize=9,
382
+ leading=12,
383
+ backColor=colors.whitesmoke,
384
+ leftIndent=12,
385
+ rightIndent=12,
386
+ spaceAfter=8,
387
+ borderPadding=4
388
+ ))
389
+
390
+ story = []
391
+
392
+ class MyDocTemplate(SimpleDocTemplate):
393
+ def __init__(self, *args, **kwargs):
394
+ self.outline_entries = []
395
+ self._outline_idx = 0
396
+ super().__init__(*args, **kwargs)
397
+
398
+
399
+ def afterFlowable(self, flowable):
400
+ from reportlab.platypus import Paragraph
401
+ if isinstance(flowable, Paragraph):
402
+ style_name = flowable.style.name
403
+ if style_name.startswith('Heading'):
404
+ try:
405
+ level = int(style_name.replace("Heading", ""))
406
+ except ValueError:
407
+ return # Not a valid heading style
408
+
409
+ # Convert to outline level (0 = H1, 1 = H2, etc.)
410
+ outline_level = level - 1
411
+
412
+ # Clamp max to 2 for PDF outline safety
413
+ outline_level = max(0, min(outline_level, 2))
414
+
415
+ # Prevent skipping levels: ensure intermediates exist
416
+ # Track previous levels (add this as a class attribute if needed)
417
+ if not hasattr(self, "_last_outline_level"):
418
+ self._last_outline_level = -1
419
+
420
+ if outline_level > self._last_outline_level + 1:
421
+ outline_level = self._last_outline_level + 1 # prevent jump
422
+
423
+ self._last_outline_level = outline_level
424
+
425
+ text = flowable.getPlainText()
426
+ key = 'heading_%s' % self.seq.nextf('heading')
427
+ self.canv.bookmarkPage(key)
428
+ self.canv.addOutlineEntry(text, key, level=outline_level, closed=False)
429
+
430
+ self.notify('TOCEntry', (outline_level, text, self.page))
431
+
432
+
433
+
434
+ def add_marking(canvas, doc, marking):
435
+ if marking:
436
+ canvas.saveState()
437
+ canvas.setFont("Helvetica", 10)
438
+ width, height = doc.pagesize
439
+ text_width = canvas.stringWidth(marking, "Helvetica", 10)
440
+ x = (width - text_width) / 2
441
+ canvas.drawString(x, height - 36, marking)
442
+ canvas.drawString(x, 36, marking)
443
+ canvas.restoreState()
444
+
445
+ if include_title_page:
446
+ story.append(Spacer(1, 120))
447
+ story.append(Paragraph(f"<b>{self.title}</b>", styles['Title']))
448
+ story.append(Spacer(1, 48))
449
+ lines = []
450
+ if author:
451
+ lines.append(str(author))
452
+ if affiliation:
453
+ lines.append(str(affiliation))
454
+ lines.append(datetime.now().strftime('%B %d, %Y'))
455
+ story.append(Paragraph("<para align='center'>" + "<br/>".join(lines) + "</para>", normal_style))
456
+ story.append(PageBreak())
457
+
458
+ if include_toc:
373
459
  toc = TableOfContents()
374
460
  toc.levelStyles = [
375
461
  ParagraphStyle(name='TOCHeading1', fontSize=14, leftIndent=20, firstLineIndent=-20, spaceBefore=10, leading=16),
376
462
  ParagraphStyle(name='TOCHeading2', fontSize=12, leftIndent=40, firstLineIndent=-20, spaceBefore=5, leading=12),
377
- ParagraphStyle(name='TOCHeading3', fontSize=10, leftIndent=60, firstLineIndent=-20, spaceBefore=5, leading=10),
378
463
  ]
379
464
  story.append(Paragraph("Table of Contents", styles["Title"]))
380
465
  story.append(toc)
381
466
  story.append(PageBreak())
382
467
 
383
- def add_marking(canvas, doc, marking):
384
- if marking:
385
- canvas.saveState()
386
- canvas.setFont("Helvetica", 10)
387
- page_width = doc.pagesize[0]
388
- text_width = canvas.stringWidth(marking, "Helvetica", 10)
389
- x_position = (page_width - text_width) / 2
390
- canvas.drawString(x_position, doc.pagesize[1] - 36, marking)
391
- canvas.drawString(x_position, 36, marking)
392
- canvas.restoreState()
393
-
394
- def render_page(page):
395
- for kind, content, _ in page.elements:
396
- if kind == "text":
397
- story.append(Paragraph(content, styles['Normal']))
398
- story.append(Spacer(1, 8))
399
-
400
- elif kind == "header":
401
- text, level = content
402
- style_key = f'Heading{min(level + 1, 4)}'
403
- header_style = styles[style_key]
404
- para = Paragraph(text, header_style)
405
- story.append(para)
406
- story.append(Spacer(1, 8))
407
-
408
- # Capture this heading for TOC/outline
409
- def capture_heading(canvas, doc, level=level, text=text):
410
- doc._last_heading = (level, text)
411
-
412
- # Hack: attach postRender callback to dummy flowable
413
- spacer = Spacer(0, 0)
414
- spacer.__dict__["postRender"] = capture_heading
415
- story.append(spacer)
416
-
417
- elif kind == "table":
418
- df = content
419
- try:
420
- data = [df.columns.tolist()] + df.values.tolist()
421
- t = Table(data, repeatRows=1)
422
- t.setStyle(TableStyle([
423
- ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor("#222C36")),
424
- ('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke),
425
- ('ALIGN', (0, 0), (-1, -1), 'LEFT'),
426
- ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
427
- ('FONTSIZE', (0, 0), (-1, 0), 11),
428
- ('BOTTOMPADDING', (0, 0), (-1, 0), 10),
429
- ('TOPPADDING', (0, 0), (-1, 0), 10),
430
- ('BACKGROUND', (0, 1), (-1, -1), colors.whitesmoke),
431
- ('GRID', (0, 0), (-1, -1), 0.5, colors.HexColor("#B0B8C1")),
432
- ('FONTNAME', (0, 1), (-1, -1), 'Helvetica'),
433
- ('FONTSIZE', (0, 1), (-1, -1), 10),
434
- ('LEFTPADDING', (0, 0), (-1, -1), 6),
435
- ('RIGHTPADDING', (0, 0), (-1, -1), 6),
436
- ('TOPPADDING', (0, 1), (-1, -1), 6),
437
- ('BOTTOMPADDING', (0, 1), (-1, -1), 6),
438
- ]))
439
- story.append(t)
468
+ def render_page(page, level=0, sec_prefix=[]):
469
+ heading_style = styles['Heading1'] if level == 0 else styles['Heading2']
470
+
471
+ # Only add the page.title as a real heading if it's a top-level page
472
+ if level == 0:
473
+ story.append(Paragraph(page.title, heading_style))
474
+ story.append(Spacer(1, 12))
475
+
476
+ for kind, content, _ in page.elements:
477
+ if kind == "text":
478
+ story.append(Paragraph(content, normal_style))
479
+ story.append(Spacer(1, 8))
480
+
481
+ elif kind == "header":
482
+ text, lvl = content
483
+ safe_lvl = max(1, min(lvl + 1, 4)) # Clamp to Heading1–Heading4
484
+ style = styles[f'Heading{safe_lvl}']
485
+ story.append(Paragraph(text, style))
486
+ story.append(Spacer(1, 8))
487
+
488
+ elif kind == "table":
489
+ df = content
490
+ try:
491
+ data = [df.columns.tolist()] + df.values.tolist()
492
+ t = Table(data, repeatRows=1)
493
+ t.setStyle(TableStyle([
494
+ ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor("#222C36")),
495
+ ('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke),
496
+ ('ALIGN', (0, 0), (-1, -1), 'LEFT'),
497
+ ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
498
+ ('FONTSIZE', (0, 0), (-1, 0), 11),
499
+ ('BOTTOMPADDING', (0, 0), (-1, 0), 10),
500
+ ('TOPPADDING', (0, 0), (-1, 0), 10),
501
+ ('BACKGROUND', (0, 1), (-1, -1), colors.whitesmoke),
502
+ ('GRID', (0, 0), (-1, -1), 0.5, colors.HexColor("#B0B8C1")),
503
+ ('FONTNAME', (0, 1), (-1, -1), 'Helvetica'),
504
+ ('FONTSIZE', (0, 1), (-1, -1), 10),
505
+ ('LEFTPADDING', (0, 0), (-1, -1), 6),
506
+ ('RIGHTPADDING', (0, 0), (-1, -1), 6),
507
+ ('TOPPADDING', (0, 1), (-1, -1), 6),
508
+ ('BOTTOMPADDING', (0, 1), (-1, -1), 6),
509
+ ]))
510
+ story.append(t)
511
+ story.append(Spacer(1, 12))
512
+ except Exception:
513
+ story.append(Paragraph("Table could not be rendered.", normal_style))
514
+
515
+ elif kind == "plot":
516
+ fig = content
517
+ try:
518
+ if hasattr(fig, "savefig"): # Matplotlib
519
+ buf = io.BytesIO()
520
+ fig.savefig(buf, format="png", bbox_inches="tight", dpi=300)
521
+ buf.seek(0)
522
+ story.append(Spacer(1, 8))
523
+ story.append(Image(buf, width=6*inch, height=4.5*inch))
440
524
  story.append(Spacer(1, 12))
441
- except Exception:
442
- story.append(Paragraph("Table could not be rendered.", styles['Normal']))
443
- elif kind == "plot":
444
- fig = content
445
- try:
446
- if hasattr(fig, "to_image"):
447
- img_bytes = fig.to_image(format="png", width=800, height=600, scale=2)
448
- img_buffer = io.BytesIO(img_bytes)
449
- img = Image(img_buffer, width=6 * inch, height=4.5 * inch)
450
- story.append(img)
451
- story.append(Spacer(1, 12))
452
- elif hasattr(fig, "savefig"):
453
- buf = io.BytesIO()
454
- fig.savefig(buf, format="png", bbox_inches="tight", dpi=300)
455
- buf.seek(0)
456
- img = Image(buf, width=6 * inch, height=4.5 * inch)
457
- story.append(img)
458
- story.append(Spacer(1, 12))
459
- except Exception as e:
460
- story.append(Paragraph(f"Plot could not be rendered: {e}", styles['Normal']))
461
- elif kind == "syntax":
462
- pass
463
- elif kind == "syntax":
464
- code, language = content
465
- style = ParagraphStyle(
466
- name='CodeBlock',
467
- fontName='Courier',
468
- fontSize=8,
469
- leading=10,
470
- leftIndent=12,
471
- rightIndent=12,
472
- spaceBefore=6,
473
- spaceAfter=6,
474
- borderPadding=6,
475
- backColor=colors.whitesmoke
476
- )
477
- # Escape special characters for XML
478
- from xml.sax.saxutils import escape
479
- code_escaped = escape(code)
480
- para = Paragraph(f"<pre>{code_escaped}</pre>", style)
481
- story.append(para)
482
-
483
- elif kind == "minipage":
484
- pass
485
-
486
- for child in getattr(page, "children", []):
487
- story.append(PageBreak())
488
- render_page(child)
489
-
490
- for page in self.pages:
491
- render_page(page)
492
- story.append(PageBreak())
493
-
494
- doc = SimpleDocTemplate(output_path, pagesize=page_size)
495
- # doc.afterFlowable = self._track_outline
496
- doc.afterFlowable = lambda flowable: getattr(flowable, "postRender", lambda c, d: None)(doc.canv, doc) or self._track_outline(doc.canv, doc)
497
-
498
- doc.build(
499
- story,
500
- onFirstPage=lambda canvas, doc: add_marking(canvas, doc, title_page_marking),
501
- onLaterPages=lambda canvas, doc: add_marking(canvas, doc, self.marking)
502
- )
525
+ elif hasattr(fig, "write_image"): # Plotly
526
+ with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmpfile:
527
+ fig.write_image(tmpfile.name, width=600, height=360, scale=2)
528
+ with open(tmpfile.name, "rb") as f:
529
+ story.append(Spacer(1, 8))
530
+ story.append(Image(io.BytesIO(f.read()), width=6*inch, height=3.6*inch))
531
+ story.append(Spacer(1, 12))
532
+ os.unlink(tmpfile.name)
533
+ except Exception as e:
534
+ story.append(Paragraph(f"Plot rendering failed: {e}", normal_style))
535
+
536
+ elif kind == "syntax":
537
+ code, language = content
538
+ from html import escape
539
+ story.append(Paragraph(f"<b>Code ({language}):</b>", normal_style))
540
+ story.append(Spacer(1, 4))
541
+ code_html = escape(code).replace(" ", "&nbsp;").replace("\n", "<br/>")
542
+ story.append(Paragraph(f"<font face='Courier'>{code_html}</font>", styles['CodeBlock']))
543
+ story.append(Spacer(1, 12))
544
+
545
+ elif kind == "minipage":
546
+ render_page(content, level=level + 1, sec_prefix=sec_prefix)
547
+
548
+ # for child in getattr(page, "children", []):
549
+ # story.append(PageBreak())
550
+ # render_page(child, level=level + 2, sec_prefix=sec_prefix + [1])
551
+
552
+ story.append(PageBreak())
553
+
554
+ for page in self.pages:
555
+ render_page(page)
556
+
557
+
558
+
559
+ doc = MyDocTemplate(
560
+ output_path,
561
+ pagesize=page_size,
562
+ rightMargin=72, leftMargin=72, topMargin=72, bottomMargin=72,
563
+ )
564
+
565
+ doc.multiBuild(
566
+ story,
567
+ onFirstPage=lambda canvas, doc: add_marking(canvas, doc, title_page_marking),
568
+ onLaterPages=lambda canvas, doc: add_marking(canvas, doc, self.marking)
569
+ )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: staticdash
3
- Version: 2025.20
3
+ Version: 2025.22
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
File without changes
File without changes