"""
montin.core.deck
=================
Defines ``Deck``, ``Plugin``, ``SlideDefaults``, and ``CellDefaults``.
"""
from __future__ import annotations
import datetime
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Hashable, Literal
from montin.cells import _UNSET
from montin.core.plugins import Plugin
from montin.core.security import Security
from montin.core.slide import Slide
# ---------------------------------------------------------------------------
# Configurable defaults
# ---------------------------------------------------------------------------
[docs]
@dataclass
class SlideDefaults:
"""Grid layout defaults applied to every :meth:`~Deck.add_slide` call.
Any value set here acts as the fallback when the corresponding argument is
omitted from ``add_slide()``. Per-call arguments always take precedence.
Attributes:
nrows: Number of grid rows (default ``1``).
ncols: Number of grid columns (default ``1``).
row_heights: CSS height for each row (e.g. ``["2fr", "1fr"]``).
``None`` distributes space evenly.
col_widths: CSS width for each column (e.g. ``["300px", "1fr"]``).
``None`` distributes space evenly.
"""
nrows: int = 1
ncols: int = 1
row_heights: list[int | str] | None = None
col_widths: list[int | str] | None = None
[docs]
@dataclass
class CellDefaults:
"""Visual and behaviour defaults applied to every ``add_*()`` cell method.
Any value set here acts as the fallback when the corresponding argument is
omitted from a cell-creation call. Per-call arguments always take precedence.
Attributes:
overflow: Whether cell content is scrollable when it overflows
(default ``True``).
copy_button: Show a copy-to-clipboard button on the cell
(default ``False``).
expand_button: Show a fullscreen-expand button on the cell
(default ``False``).
transparent: Render the cell without a background card
(default ``False``).
halign: Horizontal alignment of cell content — ``"left"``,
``"center"``, or ``"right"`` (default ``"left"``).
valign: Vertical alignment of cell content — ``"top"``,
``"middle"``, or ``"bottom"`` (default ``"top"``).
fontscale: Multiplier applied to the cell's text size (default
``1.0``). Composes on top of the deck-wide ``fontsize_scale``.
"""
overflow: bool = True
copy_button: bool = False
expand_button: bool = False
transparent: bool = False
halign: Literal["left", "center", "right"] = "left"
valign: Literal["top", "middle", "bottom"] = "top"
fontscale: float = 1.0
# ---------------------------------------------------------------------------
# Deck
# ---------------------------------------------------------------------------
[docs]
class Deck:
"""
Main container for a report deck.
Arguments:
title (str): The presentation title (required).
author (str): The presentation author (optional).
date (str): The presentation date (optional, defaults to today).
version (str): The presentation version (optional).
theme (str):
custom_css (str | Path | None): Optional path to a custom CSS file to include.
fontsize_scale (float): Multiplier applied to every font in the
presentation — slide content and the navigation chrome (sidebar,
toolbar, TOC, lightbox) alike (default ``1.0``). Spacing and layout
are unaffected. Per-cell ``fontscale`` composes on top of this.
self_contained (bool): Whether to produce a self-contained HTML file with
embedded assets (default: True).
plugins (list[Plugin]): Optional JS libraries to include, declared via the
``Plugins`` container, e.g. ``[Plugins.Plotly(), Plugins.Mermaid()]``
(default: ``[]``). Nothing is mandatory — declare only what you use.
plugin_source (Literal['cdn', 'bundled']): Default loading mode for every
plugin that doesn't set its own ``source`` (default: ``'cdn'``).
``'bundled'`` embeds the libraries for a report that works fully
offline. A ``Security(block_external=True)`` forces ``'bundled'``.
security (Security | None): Security / privacy hardening for the output
(CSP, SRI, no-referrer, Permissions-Policy, ...). ``None`` (default)
applies light, always-safe hardening; pass ``Security(...)`` to
customise — notably ``block_external=True`` for a provably offline file.
slide_defaults (SlideDefaults): Global defaults for slides (default: SlideDefaults()).
cell_defaults (CellDefaults): Global defaults for cells (default: CellDefaults()).
autosave (str | None): Optional filename to autosave after each change.
Use it when iteratively building a presentation to live-preview in
a browser. If building a large presentation, consider not using
autosave to avoid excessive writes.
autosave_level (Literal['slide', 'cell']): Whether to autosave after each
slide change or cell change (default: 'slide').
Use 'slide' on presentations with many cells to avoid excessive writes.
Only relevant if autosave is set to a filename.
size (tuple[int, int] | None): Fixed slide dimensions in pixels, e.g.
``(1366, 768)``. When set, slides become a fixed-size "stage" that is
scaled with a CSS transform to fit the available area — every element
(fonts, images, layout) scales together, just like a static PDF.
When ``None`` (default) the layout is fluid and fills the window
(current behaviour).
scale_up (bool): When ``size`` is set, allow scaling beyond the native
dimensions to fill larger screens. When ``False`` (default) the stage
never grows past 1:1 — it only shrinks on smaller windows.
keep_aspect_ratio (bool): When ``size`` is set and ``True`` (default), the
stage scales uniformly and is letterboxed. When ``False`` the stage
stretches to fill both dimensions independently (distorts content).
show_sidebar (bool): Render the slide-navigation sidebar (default
``True``). Set to ``False`` for clean single-slide / embeddable files.
show_toolbar (bool): Render the bottom navigation toolbar (default
``True``). Set to ``False`` for clean single-slide / embeddable files.
sidebar_collapsed (bool): Start with the sidebar collapsed (default
``False``). The sidebar can still be toggled open via the toolbar
button or the ``B`` key. Ignored when ``show_sidebar`` is ``False``.
A previously remembered toggle state (from ``localStorage``) takes
precedence over this default.
sidebar_search (bool): Render a regex search box at the top of the sidebar
that live-filters slides (default ``True``). Ignored when
``show_sidebar`` is ``False``.
sidebar_search_scope (Literal['title', 'title_subtitle', 'content']):
What the search regex matches against (default ``'title'``).
``'title'`` matches the sidebar title only; ``'title_subtitle'`` also
matches the slide subtitle; ``'content'`` searches the slide's full
rendered text. Note: with ``'content'``, Plotly/Mermaid bodies are not
searchable until they have rendered in the browser.
sidebar_collapsible_sections (bool): Show a caret on section items that
folds/unfolds the slides under that section (default ``True``).
Ignored when ``show_sidebar`` is ``False``.
Example::
deck = Deck(
title="Q3 Report",
author="J. Smith",
theme="default",
plugins=[Plugins.MathJax(), Plugins.Plotly()],
slide_defaults=SlideDefaults(nrows=2, ncols=2),
cell_defaults=CellDefaults(expand_button=True),
)
deck.add_title("Q3 Report")
slide = deck.add_slide("Results")
slide.add_metric(value=98.7, label="Efficiency")
deck.write("q3-report")
"""
def __init__(
self,
title: str,
author: str = "",
date: str = "",
version: str = "",
theme: str = "default",
custom_css: str | Path | None = None,
fontsize_scale: float = 1.0,
self_contained: bool = True,
plugins: list[Plugin] = [],
plugin_source: Literal['cdn', 'bundled'] = 'cdn',
slide_defaults: SlideDefaults = SlideDefaults(), # noqa: B006
cell_defaults: CellDefaults = CellDefaults(), # noqa: B006
autosave: str | None = None,
autosave_level: Literal['slide', 'cell'] = 'slide',
size: tuple[int, int] | None = None,
scale_up: bool = False,
keep_aspect_ratio: bool = True,
show_sidebar: bool = True,
show_toolbar: bool = True,
sidebar_collapsed: bool = False,
sidebar_search: bool = True,
sidebar_search_scope: Literal['title', 'title_subtitle', 'content'] = 'title',
sidebar_collapsible_sections: bool = True,
preview_height: int | None = None,
contents_folder: str | Path | None = None,
security: Security | None = None,
) -> None:
self.title = title
self.author = author
self.date = date or datetime.date.today().isoformat()
self.version = version
self.theme = theme
self.custom_css = Path(custom_css) if isinstance(custom_css, str) else custom_css
self.fontsize_scale = fontsize_scale
self.self_contained = self_contained
self.plugins = list(plugins)
self.plugin_source = plugin_source
self.slide_defaults = slide_defaults
self.cell_defaults = cell_defaults
self.autosave = autosave
self.autosave_level = autosave_level
self.size = size
self.scale_up = scale_up
self.keep_aspect_ratio = keep_aspect_ratio
self.show_sidebar = show_sidebar
self.show_toolbar = show_toolbar
self.sidebar_collapsed = sidebar_collapsed
self.sidebar_search = sidebar_search
self.sidebar_search_scope = sidebar_search_scope
self.sidebar_collapsible_sections = sidebar_collapsible_sections
self.preview_height = preview_height
self.contents_folder = contents_folder
self.security = security or Security()
self._slides: list[Slide] = []
self._slide_map: dict[Hashable, Slide] = {}
self._sections: list[dict[str, Any]] = [] # for the automatic TOC
self._slide_counter = 0
# Per-Jupyter-cell counters for notebook_unique slide ids.
self._nb_slide_state: dict = {}
# Plugin name set for fast membership checks
self._plugin_names: frozenset[str] = frozenset(p.name for p in self.plugins)
# ------------------------------------------------------------------
# Public methods
# ------------------------------------------------------------------
[docs]
def add_title(
self,
title: str,
subtitle: str = "",
notes: str = "",
title_id: Hashable | None = None,
notebook_unique: bool = False,
) -> Slide:
"""Add the cover/title slide.
Args:
title (str): Main heading.
subtitle (str): Optional secondary text.
notes (str): Presenter notes (not rendered on the slide).
title_id: Stable identifier, like ``slide_id`` on ``add_slide``.
Re-running with the same ``title_id`` replaces the title slide
in place instead of appending a duplicate — useful in notebooks.
Returns:
The newly created :class:`~montin.core.slide.Slide`.
"""
return self._make_slide(
title=title,
subtitle=subtitle,
slide_type="title",
nrows=1,
ncols=1,
row_heights=None,
col_widths=None,
notes=notes,
slide_id=title_id,
notebook_unique=notebook_unique,
)
[docs]
def add_section(
self,
title: str,
subtitle: str = "",
level: int = 1,
add_to_toc: bool = True,
show_toc: bool = True,
section_id: Hashable | None = None,
notebook_unique: bool = False,
) -> Slide:
"""Add a section-divider slide.
Args:
title (str): Section heading.
subtitle (str): Optional secondary text below the heading.
level (int): Hierarchy depth (1 = top-level, 2 = sub-section, …).
Higher levels are indented and visually dimmed in the sidebar.
add_to_toc (bool): Whether to include this section in TOC slides and
inline section TOCs (default ``True``).
show_toc (bool): Whether to render an inline TOC on this slide
highlighting the current section (default ``True``).
section_id: Stable identifier, like ``slide_id`` on ``add_slide``.
Re-running with the same ``section_id`` replaces the section (and
its TOC entry) in place instead of appending a duplicate.
Returns:
The newly created :class:`~montin.core.slide.Slide`.
"""
slide = self._make_slide(
title=title,
subtitle=subtitle,
slide_type="section",
nrows=1,
ncols=1,
row_heights=None,
col_widths=None,
notes="",
slide_id=section_id,
level=level,
show_toc=show_toc,
notebook_unique=notebook_unique,
)
if add_to_toc:
entry = {
"title": title,
"subtitle": subtitle,
"level": level,
"slide_id": slide.slide_id,
}
idx = next(
(i for i, s in enumerate(self._sections)
if s["slide_id"] == slide.slide_id),
None,
)
if idx is None:
self._sections.append(entry)
else:
self._sections[idx] = entry # replace in place, preserve TOC order
else:
# A re-run that flips add_to_toc to False must drop any prior entry.
self._sections = [
s for s in self._sections if s["slide_id"] != slide.slide_id
]
return slide
[docs]
def add_toc(
self,
title: str = "Table of Contents",
auto: bool = True,
toc_id: Hashable | None = None,
notebook_unique: bool = False,
) -> Slide:
"""Add a Table of Contents slide.
The TOC is always populated at ``write()`` time so it reflects all
sections in the deck regardless of call order.
Args:
title (str): Slide heading shown in the header bar.
auto (bool): When ``True`` (default), the TOC is built automatically
from all ``add_section()`` calls. When ``False`` the slide
is rendered with an empty TOC.
toc_id: Stable identifier, like ``slide_id`` on ``add_slide``.
Re-running with the same ``toc_id`` replaces the TOC slide in
place instead of appending a duplicate.
Returns:
The newly created :class:`~montin.core.slide.Slide`.
"""
slide = self._make_slide(
title=title,
subtitle="",
slide_type="toc",
nrows=1,
ncols=1,
row_heights=None,
col_widths=None,
notes="",
slide_id=toc_id,
notebook_unique=notebook_unique,
)
slide._auto_toc = auto # type: ignore[attr-defined]
return slide
[docs]
def add_slide(
self,
title: str,
subtitle: str = "",
nrows: Any = _UNSET,
ncols: Any = _UNSET,
row_heights: list[int | str] | None = _UNSET,
col_widths: list[int | str] | None = _UNSET,
notes: str = "",
slide_id: Hashable | None = None,
slide_defaults: SlideDefaults | None = None,
cell_defaults: CellDefaults | None = None,
notebook_unique: bool = False,
) -> Slide:
"""Add a standard content slide with an ``nrows x ncols`` cell canvas.
Args:
title (str): Slide heading shown in the header bar.
subtitle (str): Optional secondary heading shown below the title.
nrows (int): Number of grid rows. Falls back to ``slide_defaults.nrows``
then ``self.slide_defaults.nrows`` when omitted.
ncols (int): Number of grid columns. Same fallback chain as ``nrows``.
row_heights (List[str,...]): Explicit heights for each row (CSS
values such as ``"1fr"`` or ``200``). ``None`` lets the grid
distribute space evenly. Falls back through the defaults
hierarchy when omitted.
col_widths(List[str,...]): Explicit widths for each column. Same
semantics as ``row_heights``.
notes: Presenter notes attached to this slide (not rendered in the
slide itself).
slide_id: Stable identifier for this slide. Auto-generated as
``"slide-<n>"`` when ``None``. Raises ``DuplicateSlideError``
if the id is already in use.
slide_defaults: Per-call override for grid defaults. Takes
precedence over ``self.slide_defaults`` but is overridden by
any explicitly supplied ``nrows`` / ``ncols`` / ``row_heights``
/ ``col_widths`` argument.
cell_defaults: Per-call override for cell defaults. Takes
precedence over ``self.cell_defaults`
Returns:
The newly created :class:`~montin.core.slide.Slide`.
"""
sd = slide_defaults if slide_defaults is not None else self.slide_defaults
return self._make_slide(
title=title,
subtitle=subtitle,
slide_type="slide",
nrows=sd.nrows if nrows is _UNSET else nrows,
ncols=sd.ncols if ncols is _UNSET else ncols,
row_heights=sd.row_heights if row_heights is _UNSET else row_heights,
col_widths=sd.col_widths if col_widths is _UNSET else col_widths,
notes=notes,
slide_id=slide_id,
cell_defaults=cell_defaults if cell_defaults is not None else self.cell_defaults,
notebook_unique=notebook_unique,
)
[docs]
def render(self):
from montin.core.assembler import Assembler
assembler = Assembler(self)
return assembler._render()
[docs]
def write(
self,
filename: str | Path | None = None,
open_browser: bool = False,
) -> Path:
"""Trigger the Assembler, write ``<filename>.html``, and return the Path."""
from montin.core.assembler import Assembler
if (filename is None) and (self.autosave is not None):
filename = self.autosave
elif (filename is None) and (self.autosave is None):
filename = self.title.replace(" ", "-")
path = Path(filename).with_suffix(".html")
assembler = Assembler(self)
assembler.write(path)
if open_browser:
import webbrowser
webbrowser.open(path.resolve().as_uri())
return path
[docs]
def remove_slide(
self,
slide_id:Hashable
) -> None:
"""Remove a slide by its ID."""
if slide_id not in self._slide_map:
raise KeyError(f"No slide found with ID: {slide_id}")
slide = self._slide_map.pop(slide_id)
self._slides.remove(slide)
# Drop any TOC registration so a removed section leaves the TOC too.
self._sections = [s for s in self._sections if s["slide_id"] != slide_id]
[docs]
def get_slide(
self,
slide_id: Hashable,
):
"""Get a slide by its ID."""
if slide_id not in self._slide_map:
raise KeyError(
f"No slide found with ID: {slide_id}. Available slide IDs:\n- "
+ '\n- '.join([f'{k}: {v}' for k, v in self._slide_map.items()])
+ '\n')
return self._slide_map[slide_id]
# ------------------------------------------------------------------
# Internals
# ------------------------------------------------------------------
def _make_slide(
self,
title: str,
subtitle: str,
slide_type: str,
nrows: int,
ncols: int,
row_heights: list[int | str] | None,
col_widths: list[int | str] | None,
notes: str,
slide_id: Hashable | None = None,
cell_defaults: CellDefaults | None = None,
level: int = 1,
show_toc: bool = False,
notebook_unique: bool = False,
) -> Slide:
self._slide_counter += 1
# An explicit id always wins. Otherwise notebook_unique derives a stable
# id from the Jupyter cell (re-running replaces in place); failing that,
# fall back to the auto-counter.
if slide_id is None and notebook_unique:
from montin.utils.notebook import notebook_unique_id
slide_id = notebook_unique_id(self._nb_slide_state, "_nbslide")
if slide_id is None:
slide_id = f"_slide-{self._slide_counter}"
slide = Slide(
slide_id = slide_id,
title = title,
subtitle = subtitle,
slide_type = slide_type, # type: ignore[arg-type]
nrows = nrows,
ncols = ncols,
row_heights = row_heights,
col_widths = col_widths,
notes = notes,
cell_defaults = cell_defaults,
plugin_names = self._plugin_names,
parent = self,
level = level,
show_toc = show_toc,
)
# Overwrite an existing id in place (keep deck position); else append.
if slide_id in self._slide_map:
old = self._slide_map[slide_id]
self._slides[self._slides.index(old)] = slide
else:
self._slides.append(slide)
self._slide_map[slide_id] = slide
if self.autosave:
self.write(self.autosave)
return slide
# ------------------------------------------------------------------
# Read-only properties
# ------------------------------------------------------------------
@property
def slides(self) -> list[Slide]:
return list(self._slides)
@property
def slide_map(self) -> dict[Hashable, Slide]:
return dict(self._slide_map)
@property
def sections(self) -> list[dict]:
"""Read-only list of TOC-registered section metadata dicts."""
return list(self._sections)
def __repr__(self) -> str:
return (
f"Deck(title={self.title!r}, slides={len(self._slides)}, "
f"theme={self.theme!r})"
)
# ------------------------------------------------------------------
# Jupyter preview
# ------------------------------------------------------------------
def _preview_clone(
self,
slides: list[Slide],
sections: list[dict[str, Any]] | None = None,
) -> "Deck":
"""Build a chrome-free deck cloning this deck's visual configuration.
Used to render single-slide / single-cell previews through the normal
pipeline (same theme, plugins, and stage settings) without the
navigation sidebar or toolbar.
Args:
slides: Slide objects to place in the temporary deck.
sections: Optional TOC section registry to carry over (for fidelity
of section/TOC slide previews).
Returns:
A throwaway :class:`Deck` ready to pass to the Assembler.
"""
clone = Deck(
title=self.title,
author=self.author,
date=self.date,
version=self.version,
theme=self.theme,
custom_css=self.custom_css,
self_contained=self.self_contained,
plugins=self.plugins,
plugin_source=self.plugin_source,
slide_defaults=self.slide_defaults,
cell_defaults=self.cell_defaults,
size=self.size,
scale_up=self.scale_up,
keep_aspect_ratio=self.keep_aspect_ratio,
show_sidebar=False,
show_toolbar=False,
preview_height=self.preview_height,
contents_folder=self.contents_folder,
security=self.security,
)
clone._slides = list(slides)
clone._slide_map = {s.slide_id: s for s in slides}
clone._sections = list(sections) if sections else []
return clone
def _repr_html_(self) -> str:
"""Render an inline preview of the whole deck for Jupyter notebooks."""
from montin.core.assembler import Assembler
from montin.utils.notebook import (
DECK_PREVIEW_HEIGHT, iframe_srcdoc, preview_error,
)
try:
html = Assembler(self)._render()
return iframe_srcdoc(html, height=self.preview_height or DECK_PREVIEW_HEIGHT)
except Exception as exc: # never break the notebook on a preview failure
return preview_error(self, exc)