simplex-web 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- simplex/README.md +32 -0
- simplex/cli/README.md +13 -0
- simplex/cli/__init__.py +5 -0
- simplex/cli/commands.py +384 -0
- simplex/deck/README.md +19 -0
- simplex/deck/__init__.py +7 -0
- simplex/deck/_template/assets/.gitkeep +0 -0
- simplex/deck/_template/assets/code/.gitkeep +0 -0
- simplex/deck/_template/assets/figures/.gitkeep +0 -0
- simplex/deck/_template/deck.toml +11 -0
- simplex/deck/_template/manim.cfg +3 -0
- simplex/deck/_template/notes.md +27 -0
- simplex/deck/_template/refs.bib +12 -0
- simplex/deck/_template/slides/__init__.py +7 -0
- simplex/deck/_template/slides/intro.py +21 -0
- simplex/deck/config.py +207 -0
- simplex/deck/registry.py +110 -0
- simplex/deck/scaffold.py +86 -0
- simplex/deck/section.py +40 -0
- simplex/engine/README.md +9 -0
- simplex/render/README.md +46 -0
- simplex/render/__init__.py +1 -0
- simplex/render/html.py +132 -0
- simplex/render/pdf.py +32 -0
- simplex/render/pptx.py +32 -0
- simplex/render/reconcile.py +350 -0
- simplex/render/runner.py +116 -0
- simplex/render/thumbnail.py +374 -0
- simplex/slides/README.md +9 -0
- simplex/slides/components/README.md +9 -0
- simplex/theme/README.md +9 -0
- simplex/web/README.md +33 -0
- simplex/web/__init__.py +1 -0
- simplex/web/bibliography.py +248 -0
- simplex/web/bibtex.py +129 -0
- simplex/web/builder.py +321 -0
- simplex/web/callouts.py +134 -0
- simplex/web/citations.py +118 -0
- simplex/web/equations.py +79 -0
- simplex/web/notes.py +135 -0
- simplex/web/refs.py +60 -0
- simplex/web/sidenotes.py +76 -0
- simplex/web/site_config.py +71 -0
- simplex/web/slide_ref.py +54 -0
- simplex/web/static/.gitkeep +0 -0
- simplex/web/static/README.md +23 -0
- simplex/web/static/fonts/lato/lato-latin-400-italic.woff2 +0 -0
- simplex/web/static/fonts/lato/lato-latin-400-normal.woff2 +0 -0
- simplex/web/static/fonts/lato/lato-latin-700-italic.woff2 +0 -0
- simplex/web/static/fonts/lato/lato-latin-700-normal.woff2 +0 -0
- simplex/web/static/fonts/lato/lato-latin-900-normal.woff2 +0 -0
- simplex/web/static/fonts/merriweather/merriweather-latin-400-italic.woff2 +0 -0
- simplex/web/static/fonts/merriweather/merriweather-latin-400-normal.woff2 +0 -0
- simplex/web/static/fonts/merriweather/merriweather-latin-700-italic.woff2 +0 -0
- simplex/web/static/fonts/merriweather/merriweather-latin-700-normal.woff2 +0 -0
- simplex/web/static/fonts/merriweather/merriweather-latin-900-normal.woff2 +0 -0
- simplex/web/static/htmx.min.js +1 -0
- simplex/web/static/katex/auto-render.min.js +1 -0
- simplex/web/static/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- simplex/web/static/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
- simplex/web/static/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
- simplex/web/static/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- simplex/web/static/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
- simplex/web/static/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- simplex/web/static/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- simplex/web/static/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- simplex/web/static/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- simplex/web/static/katex/katex.min.css +1 -0
- simplex/web/static/katex/katex.min.js +1 -0
- simplex/web/static/lucide/README.md +7 -0
- simplex/web/static/lucide/lucide.min.js +12 -0
- simplex/web/static/notes.js +68 -0
- simplex/web/static/reveal.js/reset.css +30 -0
- simplex/web/static/reveal.js/reveal.css +8 -0
- simplex/web/static/reveal.js/reveal.js +9 -0
- simplex/web/static/simplex.css +1870 -0
- simplex/web/static/tailwind.js +64 -0
- simplex/web/static/viewer.js +428 -0
- simplex/web/templates/README.md +19 -0
- simplex/web/templates/_carousel.html +117 -0
- simplex/web/templates/base.html +110 -0
- simplex/web/templates/deck.html +149 -0
- simplex/web/templates/index.html +20 -0
- simplex/web/templates/revealjs.html.j2 +374 -0
- simplex/web/templates/section.html +74 -0
- simplex/web/vendor.py +148 -0
- simplex_web-0.2.0.dist-info/METADATA +166 -0
- simplex_web-0.2.0.dist-info/RECORD +91 -0
- simplex_web-0.2.0.dist-info/WHEEL +4 -0
- simplex_web-0.2.0.dist-info/entry_points.txt +2 -0
- simplex_web-0.2.0.dist-info/licenses/LICENSE +21 -0
simplex/deck/config.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""DeckConfig -- pydantic model loaded from each deck's deck.toml.
|
|
2
|
+
|
|
3
|
+
Two scene-list spellings are accepted:
|
|
4
|
+
|
|
5
|
+
- ``entrypoints = ["slides.intro:Title", ...]`` -- preferred, points at scene
|
|
6
|
+
classes inside the deck's ``slides/`` package.
|
|
7
|
+
- ``scenes = ["Title", ...]`` -- legacy, bare class names in a top-level
|
|
8
|
+
``slides.py``. Kept for backwards compatibility with the single-file layout.
|
|
9
|
+
|
|
10
|
+
``section_slug`` is populated by the registry, not the author.
|
|
11
|
+
|
|
12
|
+
Three nested override types tune per-deck or per-main-slide behaviour:
|
|
13
|
+
|
|
14
|
+
- ``SlideOverride`` -- per-main-slide tweaks (thumbnail path/index, notes
|
|
15
|
+
anchor, order). Keyed by the main slide's ``name=`` in ``deck.slides``.
|
|
16
|
+
- ``WebOverride`` -- per-deck portal + RevealJS palette overrides
|
|
17
|
+
(``deck.web``). Every field is optional; ``resolved_web_palette()``
|
|
18
|
+
merges with the active theme's defaults field-by-field.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import re
|
|
22
|
+
import tomllib
|
|
23
|
+
from datetime import date
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Self
|
|
26
|
+
|
|
27
|
+
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
|
|
28
|
+
|
|
29
|
+
from simplex.theme.presets import get as get_theme
|
|
30
|
+
from simplex.theme.tokens import WebPalette
|
|
31
|
+
|
|
32
|
+
_SLUG = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
|
33
|
+
_ENTRYPOINT = re.compile(r"^[A-Za-z_][\w.]*:[A-Za-z_]\w*$")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SlideOverride(BaseModel):
|
|
37
|
+
"""Per-main-slide override. Keyed by the ``name=`` passed to next_slide."""
|
|
38
|
+
|
|
39
|
+
model_config = ConfigDict(frozen=True)
|
|
40
|
+
thumbnail: Path | None = None
|
|
41
|
+
thumbnail_section_index: int = -2
|
|
42
|
+
notes_anchor: str | None = None
|
|
43
|
+
order_override: float | None = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class WebOverride(BaseModel):
|
|
47
|
+
"""Per-deck portal + RevealJS overrides. Every field is optional.
|
|
48
|
+
|
|
49
|
+
Resolution: ``deck.web`` field if non-None > ``theme.web_palette`` field >
|
|
50
|
+
``WebPalette()`` default. RevealJS-specific fields (``transition``,
|
|
51
|
+
``controls``, ...) are passed straight to the converter.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
model_config = ConfigDict(frozen=True)
|
|
55
|
+
accent: str | None = None
|
|
56
|
+
background: str | None = None
|
|
57
|
+
surface: str | None = None
|
|
58
|
+
text_primary: str | None = None
|
|
59
|
+
text_muted: str | None = None
|
|
60
|
+
link: str | None = None
|
|
61
|
+
code_background: str | None = None
|
|
62
|
+
font_family_sans: str | None = None
|
|
63
|
+
font_family_mono: str | None = None
|
|
64
|
+
font_size_base: str | None = None
|
|
65
|
+
|
|
66
|
+
# RevealJS knobs (forwarded to manim_slides.convert.RevealJS kwargs).
|
|
67
|
+
# Default ``"none"``: the next video replaces the previous one with no
|
|
68
|
+
# animation. This keeps both desktop and mobile playback direction-free.
|
|
69
|
+
transition: str = "none"
|
|
70
|
+
controls: bool = True
|
|
71
|
+
progress: bool = True
|
|
72
|
+
hash_navigation: bool = True
|
|
73
|
+
|
|
74
|
+
# Slide-presentation chrome. These used to be drawn into each frame
|
|
75
|
+
# by ``make_chrome(..., page=)``; they now live in the RevealJS host
|
|
76
|
+
# so toggling them is free (no re-render).
|
|
77
|
+
show_slide_number: bool = False
|
|
78
|
+
show_clock: bool = False
|
|
79
|
+
|
|
80
|
+
# Homepage/section carousel preview. ``carousel_gif`` points to a
|
|
81
|
+
# user-authored GIF relative to the deck directory. When omitted,
|
|
82
|
+
# ``carousel_gif_slides`` can select 1-based main-slide indexes to
|
|
83
|
+
# synthesize a small progressive preview from rendered video segments.
|
|
84
|
+
carousel_gif: Path | None = None
|
|
85
|
+
carousel_gif_slides: tuple[int, ...] = ()
|
|
86
|
+
|
|
87
|
+
@field_validator("carousel_gif_slides")
|
|
88
|
+
@classmethod
|
|
89
|
+
def _carousel_gif_slides_positive(cls, value: tuple[int, ...]) -> tuple[int, ...]:
|
|
90
|
+
if any(i < 1 for i in value):
|
|
91
|
+
raise ValueError("carousel_gif_slides uses 1-based slide indexes")
|
|
92
|
+
return value
|
|
93
|
+
|
|
94
|
+
# Escape hatches.
|
|
95
|
+
custom_css_path: Path | None = None
|
|
96
|
+
template: Path | None = None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class DeckConfig(BaseModel):
|
|
100
|
+
model_config = ConfigDict(frozen=True)
|
|
101
|
+
slug: str
|
|
102
|
+
title: str
|
|
103
|
+
summary: str = ""
|
|
104
|
+
tags: tuple[str, ...] = ()
|
|
105
|
+
theme: str = "dastimator_dark"
|
|
106
|
+
scenes: tuple[str, ...] = ()
|
|
107
|
+
entrypoints: tuple[str, ...] = ()
|
|
108
|
+
quality: str = "high_quality"
|
|
109
|
+
voiceover: bool = False
|
|
110
|
+
category: str | None = None
|
|
111
|
+
duration_minutes: int | None = None
|
|
112
|
+
created_at: date | None = None
|
|
113
|
+
order: int = 1000
|
|
114
|
+
path: Path
|
|
115
|
+
section_slug: str = "featured"
|
|
116
|
+
|
|
117
|
+
# v0.2 additions.
|
|
118
|
+
caching: bool = True
|
|
119
|
+
self_test: bool = False
|
|
120
|
+
render_quality_dev: str = "low_quality"
|
|
121
|
+
render_quality_release: str = "high_quality"
|
|
122
|
+
slides: dict[str, SlideOverride] = {}
|
|
123
|
+
slide_order: tuple[str, ...] = ()
|
|
124
|
+
web: WebOverride = WebOverride()
|
|
125
|
+
|
|
126
|
+
@field_validator("slug")
|
|
127
|
+
@classmethod
|
|
128
|
+
def _slug_format(cls, value: str) -> str:
|
|
129
|
+
if not _SLUG.match(value):
|
|
130
|
+
raise ValueError(f"slug must be kebab-case (a-z0-9 with single hyphens), got {value!r}")
|
|
131
|
+
return value
|
|
132
|
+
|
|
133
|
+
@field_validator("entrypoints")
|
|
134
|
+
@classmethod
|
|
135
|
+
def _entrypoint_format(cls, value: tuple[str, ...]) -> tuple[str, ...]:
|
|
136
|
+
for ep in value:
|
|
137
|
+
if not _ENTRYPOINT.match(ep):
|
|
138
|
+
raise ValueError(f"entrypoint must be 'module[.sub]:ClassName', got {ep!r}")
|
|
139
|
+
return value
|
|
140
|
+
|
|
141
|
+
@model_validator(mode="after")
|
|
142
|
+
def _at_least_one_scene_source(self) -> Self:
|
|
143
|
+
return self
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def scene_specs(self) -> tuple[str, ...]:
|
|
147
|
+
"""Return entrypoints if present, else legacy ``slides.py``-relative scenes."""
|
|
148
|
+
if self.entrypoints:
|
|
149
|
+
return self.entrypoints
|
|
150
|
+
return tuple(f"slides:{name}" for name in self.scenes)
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def scene_class_names(self) -> tuple[str, ...]:
|
|
154
|
+
"""Bare class names extracted from ``scene_specs``."""
|
|
155
|
+
return tuple(spec.rsplit(":", 1)[-1] for spec in self.scene_specs)
|
|
156
|
+
|
|
157
|
+
def resolve_entrypoints(self) -> tuple[tuple[Path, tuple[str, ...]], ...]:
|
|
158
|
+
"""Group entrypoints by their source file, in declaration order."""
|
|
159
|
+
groups: dict[Path, list[str]] = {}
|
|
160
|
+
for spec in self.scene_specs:
|
|
161
|
+
module, _, class_name = spec.partition(":")
|
|
162
|
+
file_path = self._module_to_file(module)
|
|
163
|
+
groups.setdefault(file_path, []).append(class_name)
|
|
164
|
+
return tuple((file_path, tuple(names)) for file_path, names in groups.items())
|
|
165
|
+
|
|
166
|
+
def resolved_web_palette(self) -> WebPalette:
|
|
167
|
+
"""Merge per-deck ``web`` overrides over the active theme's palette.
|
|
168
|
+
|
|
169
|
+
Returns a fully-resolved ``WebPalette`` (every field set). Used by
|
|
170
|
+
the web builder + RevealJS template injection.
|
|
171
|
+
"""
|
|
172
|
+
theme = get_theme(self.theme)
|
|
173
|
+
base = theme.web_palette
|
|
174
|
+
web = self.web
|
|
175
|
+
return WebPalette(
|
|
176
|
+
accent=web.accent or base.accent,
|
|
177
|
+
background=web.background or base.background,
|
|
178
|
+
surface=web.surface or base.surface,
|
|
179
|
+
text_primary=web.text_primary or base.text_primary,
|
|
180
|
+
text_muted=web.text_muted or base.text_muted,
|
|
181
|
+
link=web.link or base.link,
|
|
182
|
+
code_background=web.code_background or base.code_background,
|
|
183
|
+
font_family_sans=web.font_family_sans or base.font_family_sans,
|
|
184
|
+
font_family_mono=web.font_family_mono or base.font_family_mono,
|
|
185
|
+
font_size_base=web.font_size_base or base.font_size_base,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
def _module_to_file(self, module: str) -> Path:
|
|
189
|
+
"""Map ``slides.foo.bar`` to the deck-relative ``.py`` file."""
|
|
190
|
+
parts = module.split(".")
|
|
191
|
+
module_path = self.path.joinpath(*parts)
|
|
192
|
+
as_file = module_path.with_suffix(".py")
|
|
193
|
+
if as_file.exists():
|
|
194
|
+
return as_file
|
|
195
|
+
as_pkg = module_path / "__init__.py"
|
|
196
|
+
if as_pkg.exists():
|
|
197
|
+
return as_pkg
|
|
198
|
+
raise FileNotFoundError(
|
|
199
|
+
f"deck {self.slug!r}: entrypoint module {module!r} resolves to neither "
|
|
200
|
+
f"{as_file} nor {as_pkg}"
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
@classmethod
|
|
204
|
+
def load(cls, deck_dir: Path, *, section_slug: str = "featured") -> Self:
|
|
205
|
+
toml_path = deck_dir / "deck.toml"
|
|
206
|
+
data = tomllib.loads(toml_path.read_text(encoding="utf-8"))
|
|
207
|
+
return cls(**data, path=deck_dir, section_slug=section_slug)
|
simplex/deck/registry.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Discover sections + decks on disk.
|
|
2
|
+
|
|
3
|
+
Layout:
|
|
4
|
+
|
|
5
|
+
decks/
|
|
6
|
+
<featured-slug>/deck.toml # featured section
|
|
7
|
+
<section>/_section.toml # carousel metadata (optional)
|
|
8
|
+
<section>/<slug>/deck.toml # sectioned decks
|
|
9
|
+
|
|
10
|
+
Recursion is exactly one level deep. Decks placed deeper than that raise.
|
|
11
|
+
The walker preserves legacy single-file `slides.py` decks as well as the
|
|
12
|
+
new `slides/` package layout.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from pydantic import BaseModel, ConfigDict
|
|
18
|
+
|
|
19
|
+
from simplex.deck.config import DeckConfig
|
|
20
|
+
from simplex.deck.section import FEATURED_SLUG, SectionConfig
|
|
21
|
+
|
|
22
|
+
_SKIP_PREFIXES = ("_", ".")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _is_skipped(name: str) -> bool:
|
|
26
|
+
return name.startswith(_SKIP_PREFIXES)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _is_deck_dir(p: Path) -> bool:
|
|
30
|
+
return p.is_dir() and not _is_skipped(p.name) and (p / "deck.toml").exists()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Section(BaseModel):
|
|
34
|
+
model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True)
|
|
35
|
+
config: SectionConfig
|
|
36
|
+
decks: tuple[DeckConfig, ...]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class SectionedRegistry(BaseModel):
|
|
40
|
+
model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True)
|
|
41
|
+
sections: tuple[Section, ...]
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def all_decks(self) -> tuple[DeckConfig, ...]:
|
|
45
|
+
return tuple(d for s in self.sections for d in s.decks)
|
|
46
|
+
|
|
47
|
+
def find_deck(self, slug: str) -> DeckConfig | None:
|
|
48
|
+
for deck in self.all_decks:
|
|
49
|
+
if deck.slug == slug:
|
|
50
|
+
return deck
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _section_sort_key(
|
|
55
|
+
section: Section,
|
|
56
|
+
default_order: tuple[str, ...],
|
|
57
|
+
) -> tuple[int, int, str]:
|
|
58
|
+
cfg = section.config
|
|
59
|
+
if cfg.slug in default_order:
|
|
60
|
+
return (0, default_order.index(cfg.slug), cfg.title)
|
|
61
|
+
return (1, cfg.order, cfg.title)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def discover(
|
|
65
|
+
decks_dir: Path,
|
|
66
|
+
*,
|
|
67
|
+
default_section_order: tuple[str, ...] = (),
|
|
68
|
+
) -> SectionedRegistry:
|
|
69
|
+
"""Return every section + deck discoverable under `decks_dir`."""
|
|
70
|
+
if not decks_dir.exists():
|
|
71
|
+
return SectionedRegistry(sections=())
|
|
72
|
+
|
|
73
|
+
featured_decks: list[DeckConfig] = []
|
|
74
|
+
sections: list[Section] = []
|
|
75
|
+
|
|
76
|
+
for entry in sorted(decks_dir.iterdir()):
|
|
77
|
+
if _is_skipped(entry.name):
|
|
78
|
+
continue
|
|
79
|
+
if _is_deck_dir(entry):
|
|
80
|
+
featured_decks.append(DeckConfig.load(entry, section_slug=FEATURED_SLUG))
|
|
81
|
+
continue
|
|
82
|
+
if not entry.is_dir():
|
|
83
|
+
continue
|
|
84
|
+
cfg = SectionConfig.load(entry)
|
|
85
|
+
decks: list[DeckConfig] = []
|
|
86
|
+
for child in sorted(entry.iterdir()):
|
|
87
|
+
if _is_skipped(child.name):
|
|
88
|
+
continue
|
|
89
|
+
if _is_deck_dir(child):
|
|
90
|
+
decks.append(DeckConfig.load(child, section_slug=cfg.slug))
|
|
91
|
+
continue
|
|
92
|
+
if child.is_dir() and any(child.rglob("deck.toml")):
|
|
93
|
+
raise ValueError(
|
|
94
|
+
f"decks/ supports exactly one level of sections; "
|
|
95
|
+
f"found a deck.toml below {child}"
|
|
96
|
+
)
|
|
97
|
+
if decks:
|
|
98
|
+
decks.sort(key=lambda d: (d.order, d.slug))
|
|
99
|
+
sections.append(Section(config=cfg, decks=tuple(decks)))
|
|
100
|
+
|
|
101
|
+
if featured_decks:
|
|
102
|
+
featured_decks.sort(key=lambda d: (d.order, d.slug))
|
|
103
|
+
featured = Section(
|
|
104
|
+
config=SectionConfig.featured(),
|
|
105
|
+
decks=tuple(featured_decks),
|
|
106
|
+
)
|
|
107
|
+
sections.insert(0, featured)
|
|
108
|
+
|
|
109
|
+
sections.sort(key=lambda s: _section_sort_key(s, default_section_order))
|
|
110
|
+
return SectionedRegistry(sections=tuple(sections))
|
simplex/deck/scaffold.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Materialise a new deck folder from the bundled ``_template/``.
|
|
2
|
+
|
|
3
|
+
The template ships with the ``simplex`` package (``simplex/deck/_template/``)
|
|
4
|
+
so ``simplex new`` works from any directory, not just inside the simplex
|
|
5
|
+
checkout. Callers can still pass an explicit ``template_dir`` to override.
|
|
6
|
+
|
|
7
|
+
`simplex new <section>/<slug>` creates `decks/<section>/<slug>/`.
|
|
8
|
+
`simplex new <slug>` creates `decks/<slug>/` (featured section).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import shutil
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from simplex.deck.section import FEATURED_SLUG
|
|
16
|
+
|
|
17
|
+
_TOKENS = ("__SLUG__", "__SECTION__", "__TITLE__", "__CREATED_AT__")
|
|
18
|
+
_BUNDLED_TEMPLATE = Path(__file__).resolve().parent / "_template"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _humanise(slug: str) -> str:
|
|
22
|
+
return slug.replace("-", " ").replace("_", " ").title()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def split_target(target: str) -> tuple[str, str]:
|
|
26
|
+
"""Split a `simplex new` argument into (section, slug)."""
|
|
27
|
+
target = target.strip().strip("/")
|
|
28
|
+
if "/" in target:
|
|
29
|
+
section, _, slug = target.partition("/")
|
|
30
|
+
if not section or not slug or "/" in slug:
|
|
31
|
+
raise ValueError(f"target must be 'section/slug' or 'slug', got {target!r}")
|
|
32
|
+
return section, slug
|
|
33
|
+
return FEATURED_SLUG, target
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _substitute_tokens(path: Path, slug: str, section: str) -> None:
|
|
37
|
+
text = path.read_text(encoding="utf-8")
|
|
38
|
+
replaced = (
|
|
39
|
+
text.replace("__SLUG__", slug)
|
|
40
|
+
.replace("__SECTION__", section)
|
|
41
|
+
.replace("__TITLE__", _humanise(slug))
|
|
42
|
+
.replace("__CREATED_AT__", datetime.now(UTC).date().isoformat())
|
|
43
|
+
)
|
|
44
|
+
if replaced != text:
|
|
45
|
+
path.write_text(replaced, encoding="utf-8")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def scaffold(
|
|
49
|
+
target: str,
|
|
50
|
+
decks_dir: Path,
|
|
51
|
+
*,
|
|
52
|
+
template_dir: Path | None = None,
|
|
53
|
+
) -> Path:
|
|
54
|
+
"""Copy the deck template into ``decks/[section/]<slug>/``, substituting tokens.
|
|
55
|
+
|
|
56
|
+
The template defaults to the one bundled with the ``simplex`` package so
|
|
57
|
+
that ``simplex new`` works in any project. Tests (and power users wanting
|
|
58
|
+
a custom starter) pass ``template_dir`` to point at a different source.
|
|
59
|
+
"""
|
|
60
|
+
template = template_dir if template_dir is not None else _BUNDLED_TEMPLATE
|
|
61
|
+
if not template.exists():
|
|
62
|
+
raise FileNotFoundError(f"_template not found at {template}")
|
|
63
|
+
|
|
64
|
+
section, slug = split_target(target)
|
|
65
|
+
|
|
66
|
+
if section == FEATURED_SLUG:
|
|
67
|
+
dest = decks_dir / slug
|
|
68
|
+
else:
|
|
69
|
+
section_dir = decks_dir / section
|
|
70
|
+
section_dir.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
dest = section_dir / slug
|
|
72
|
+
|
|
73
|
+
if dest.exists():
|
|
74
|
+
raise FileExistsError(f"deck already exists at {dest}")
|
|
75
|
+
|
|
76
|
+
shutil.copytree(template, dest)
|
|
77
|
+
for token_file in dest.rglob("*"):
|
|
78
|
+
if not token_file.is_file():
|
|
79
|
+
continue
|
|
80
|
+
if token_file.suffix.lower() in {".png", ".jpg", ".jpeg", ".pdf", ".mp4"}:
|
|
81
|
+
continue
|
|
82
|
+
try:
|
|
83
|
+
_substitute_tokens(token_file, slug=slug, section=section)
|
|
84
|
+
except UnicodeDecodeError:
|
|
85
|
+
continue
|
|
86
|
+
return dest
|
simplex/deck/section.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""SectionConfig -- metadata for one carousel/subject under `decks/<dir>/`."""
|
|
2
|
+
|
|
3
|
+
import tomllib
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Self
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict
|
|
8
|
+
|
|
9
|
+
FEATURED_SLUG = "featured"
|
|
10
|
+
FEATURED_TITLE = "Featured"
|
|
11
|
+
_SECTION_TOML = "_section.toml"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _humanise(slug: str) -> str:
|
|
15
|
+
return slug.replace("-", " ").replace("_", " ").title()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SectionConfig(BaseModel):
|
|
19
|
+
model_config = ConfigDict(frozen=True)
|
|
20
|
+
slug: str
|
|
21
|
+
title: str
|
|
22
|
+
order: int = 1000
|
|
23
|
+
blurb: str = ""
|
|
24
|
+
icon: str | None = None
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
def load(cls, section_dir: Path) -> Self:
|
|
28
|
+
"""Load `_section.toml` for `section_dir`; fall back to dir-name defaults."""
|
|
29
|
+
toml_path = section_dir / _SECTION_TOML
|
|
30
|
+
data: dict[str, object] = {}
|
|
31
|
+
if toml_path.exists():
|
|
32
|
+
data = dict(tomllib.loads(toml_path.read_text(encoding="utf-8")))
|
|
33
|
+
data.setdefault("slug", section_dir.name)
|
|
34
|
+
data.setdefault("title", _humanise(section_dir.name))
|
|
35
|
+
return cls(**data) # type: ignore[arg-type]
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def featured(cls) -> Self:
|
|
39
|
+
"""Synthetic section for decks placed directly under `decks/`."""
|
|
40
|
+
return cls(slug=FEATURED_SLUG, title=FEATURED_TITLE, order=0)
|
simplex/engine/README.md
ADDED
simplex/render/README.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# render/
|
|
2
|
+
|
|
3
|
+
Manim-slides subprocess invocation, native-section reconcile, thumbnail
|
|
4
|
+
extraction, PDF / PowerPoint export, HTML viewer emission.
|
|
5
|
+
|
|
6
|
+
## Public surface
|
|
7
|
+
|
|
8
|
+
- `runner.render(deck, output_dir, scenes=(), write_last_frame=False)` --
|
|
9
|
+
spawns `manim-slides render` with `--save_sections` (and
|
|
10
|
+
`--disable_caching` when `deck.caching = False`).
|
|
11
|
+
- `pdf.export(deck, output_dir)` -- in-process via
|
|
12
|
+
`manim_slides.convert.PDF`.
|
|
13
|
+
- `pptx.export(deck, output_dir)` -- in-process via
|
|
14
|
+
`manim_slides.convert.PowerPoint`.
|
|
15
|
+
- `reconcile.build_manifest(deck, media_dir)` -> `DeckManifest`
|
|
16
|
+
(a tuple of `MainSlide`, each with its own `subsections`).
|
|
17
|
+
- `thumbnail.generate(deck, manifest, site_deck_dir, cache_dir)` --
|
|
18
|
+
ffmpeg last-frame extraction (default rule: second-to-last subsection).
|
|
19
|
+
- `html.render_html(deck, manifest, output_dir, static_prefix)` --
|
|
20
|
+
Jinja-renders `web/templates/revealjs.html.j2` with the main/sub tree.
|
|
21
|
+
|
|
22
|
+
## Smart compilation
|
|
23
|
+
|
|
24
|
+
The plugin sets `manim.config.save_sections = True`. Combined with manim's
|
|
25
|
+
per-animation hash cache, re-editing one animation re-encodes only that
|
|
26
|
+
animation; sections of only-cached animations are stitched from disk. No
|
|
27
|
+
separate Simplex render cache -- run `uv run simplex clean --deck <slug>`
|
|
28
|
+
to force a clean re-render.
|
|
29
|
+
|
|
30
|
+
## Reconcile
|
|
31
|
+
|
|
32
|
+
`build_manifest` walks each scene's
|
|
33
|
+
`media/videos/<src>/<q>/sections/<Scene>.json` (written by manim when
|
|
34
|
+
`save_sections=True`): every `type.startswith("simplex.main")` (and the
|
|
35
|
+
auto-created first `default.normal`) starts a new `MainSlide`; everything
|
|
36
|
+
else attaches as a `Subsection` of the current main. The parallel
|
|
37
|
+
`media/slides/<Scene>.json` is consumed only by the PDF / PPTX converters.
|
|
38
|
+
|
|
39
|
+
## Don't
|
|
40
|
+
|
|
41
|
+
- Don't shell-quote (`shell=True`); always pass arg lists.
|
|
42
|
+
- Don't add a parallel cache stamp -- the manim per-animation cache is
|
|
43
|
+
already content-addressable.
|
|
44
|
+
- Don't write outside `output_dir`.
|
|
45
|
+
- Don't read manim-slides' `PresentationConfig` JSON for hierarchy info;
|
|
46
|
+
the section JSON is the source of truth.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Render pipeline: manim-slides subprocess + reconcile + html/pdf/pptx export."""
|
simplex/render/html.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Render ``slides.html`` for one deck from the reconciled main/sub manifest.
|
|
2
|
+
|
|
3
|
+
Why we keep a custom template (instead of ``manim_slides.convert.RevealJS``):
|
|
4
|
+
|
|
5
|
+
The template at ``web/templates/revealjs.html.j2`` carries a polished
|
|
6
|
+
RevealJS host -- postMessage bridge to the parent ``deck.html``, touch tap
|
|
7
|
+
zones, disabled RevealJS layout (so videos fill the iframe natively),
|
|
8
|
+
custom progress bar styling via CSS variables. The manim-slides default
|
|
9
|
+
template doesn't have any of that. We do still use
|
|
10
|
+
``manim_slides.convert.PDF`` / ``PowerPoint`` for those formats; they have
|
|
11
|
+
no custom layout requirements.
|
|
12
|
+
|
|
13
|
+
The web palette is injected as a ``<style>:root {…}</style>`` block at the
|
|
14
|
+
top of ``<head>`` (theme defaults + per-deck overrides via
|
|
15
|
+
``DeckConfig.resolved_web_palette()``). A per-deck ``[web] custom_css_path``
|
|
16
|
+
is appended verbatim as a second ``<style>`` block.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import shutil
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from jinja2 import Environment, PackageLoader, select_autoescape
|
|
24
|
+
|
|
25
|
+
from simplex.deck.config import DeckConfig
|
|
26
|
+
from simplex.manifest import DeckManifest, Subsection
|
|
27
|
+
from simplex.theme.web_css import render_web_css
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class _SubView:
|
|
32
|
+
name: str
|
|
33
|
+
section_type: str
|
|
34
|
+
video_href: str | None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True, slots=True)
|
|
38
|
+
class _MainView:
|
|
39
|
+
index: int
|
|
40
|
+
scene: str
|
|
41
|
+
name: str
|
|
42
|
+
subsections: tuple[_SubView, ...]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _env() -> Environment:
|
|
46
|
+
return Environment(
|
|
47
|
+
loader=PackageLoader("simplex.web", "templates"),
|
|
48
|
+
autoescape=select_autoescape(["html", "j2"]),
|
|
49
|
+
trim_blocks=True,
|
|
50
|
+
lstrip_blocks=True,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _copy_segments(manifest: DeckManifest, dest_dir: Path) -> list[_MainView]:
|
|
55
|
+
"""Copy every subsection video into ``dest_dir/segments/`` with stable names."""
|
|
56
|
+
seg_dir = dest_dir / "segments"
|
|
57
|
+
out: list[_MainView] = []
|
|
58
|
+
for main in manifest.main_slides:
|
|
59
|
+
sub_views: list[_SubView] = []
|
|
60
|
+
for sub_idx, sub in enumerate(main.subsections):
|
|
61
|
+
sub_views.append(
|
|
62
|
+
_SubView(
|
|
63
|
+
name=sub.name,
|
|
64
|
+
section_type=sub.section_type.value,
|
|
65
|
+
video_href=_copy_one(sub, main.index, sub_idx, seg_dir, dest_dir),
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
out.append(
|
|
69
|
+
_MainView(
|
|
70
|
+
index=main.index,
|
|
71
|
+
scene=main.scene,
|
|
72
|
+
name=main.name,
|
|
73
|
+
subsections=tuple(sub_views),
|
|
74
|
+
)
|
|
75
|
+
)
|
|
76
|
+
return out
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _copy_one(
|
|
80
|
+
sub: Subsection,
|
|
81
|
+
main_idx: int,
|
|
82
|
+
sub_idx: int,
|
|
83
|
+
seg_dir: Path,
|
|
84
|
+
dest_dir: Path,
|
|
85
|
+
) -> str | None:
|
|
86
|
+
if sub.video is None or not sub.video.exists():
|
|
87
|
+
return None
|
|
88
|
+
seg_dir.mkdir(parents=True, exist_ok=True)
|
|
89
|
+
target = seg_dir / f"{main_idx:04d}_{sub_idx:02d}.mp4"
|
|
90
|
+
if not target.exists() or target.stat().st_mtime < sub.video.stat().st_mtime:
|
|
91
|
+
shutil.copy2(sub.video, target)
|
|
92
|
+
return target.relative_to(dest_dir).as_posix()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def render_html(
|
|
96
|
+
deck: DeckConfig,
|
|
97
|
+
manifest: DeckManifest,
|
|
98
|
+
*,
|
|
99
|
+
output_dir: Path,
|
|
100
|
+
static_prefix: str,
|
|
101
|
+
watch: bool = False,
|
|
102
|
+
) -> Path:
|
|
103
|
+
"""Write ``output_dir/slides.html`` and copy its video segments.
|
|
104
|
+
|
|
105
|
+
`watch=True` includes an SSE client snippet that listens to
|
|
106
|
+
`/_simplex/events` for live reload (used by `simplex serve --watch`).
|
|
107
|
+
"""
|
|
108
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
109
|
+
main_views = _copy_segments(manifest, output_dir)
|
|
110
|
+
palette_css = render_web_css(deck.resolved_web_palette())
|
|
111
|
+
deck_custom_css = ""
|
|
112
|
+
if deck.web.custom_css_path is not None:
|
|
113
|
+
candidate = deck.path / deck.web.custom_css_path
|
|
114
|
+
if candidate.exists():
|
|
115
|
+
deck_custom_css = candidate.read_text(encoding="utf-8")
|
|
116
|
+
|
|
117
|
+
template = _env().get_template("revealjs.html.j2")
|
|
118
|
+
html = template.render(
|
|
119
|
+
deck=deck,
|
|
120
|
+
main_slides=main_views,
|
|
121
|
+
main_slide_count=len(main_views),
|
|
122
|
+
static_prefix=static_prefix.rstrip("/"),
|
|
123
|
+
palette_css=palette_css,
|
|
124
|
+
deck_custom_css=deck_custom_css,
|
|
125
|
+
transition=deck.web.transition,
|
|
126
|
+
show_slide_number=deck.web.show_slide_number,
|
|
127
|
+
show_clock=deck.web.show_clock,
|
|
128
|
+
watch=watch,
|
|
129
|
+
)
|
|
130
|
+
out = output_dir / "slides.html"
|
|
131
|
+
out.write_text(html, encoding="utf-8")
|
|
132
|
+
return out
|
simplex/render/pdf.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Convert a rendered deck to PDF via ``manim_slides.convert.PDF`` in-process.
|
|
2
|
+
|
|
3
|
+
The PDF converter takes the per-scene ``PresentationConfig`` objects
|
|
4
|
+
written by manim-slides during render (under ``<output_dir>/slides/*.json``)
|
|
5
|
+
and writes one combined PDF. No subprocess, no shell.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from simplex.deck.config import DeckConfig
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def export(deck: DeckConfig, *, output_dir: Path) -> Path:
|
|
14
|
+
"""Write ``<output_dir>/<slug>.pdf`` from manim-slides' rendered scenes."""
|
|
15
|
+
from manim_slides.convert import PDF
|
|
16
|
+
from manim_slides.present import get_scenes_presentation_config
|
|
17
|
+
|
|
18
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
19
|
+
media_dir = output_dir.resolve()
|
|
20
|
+
pdf_path = media_dir / f"{deck.slug}.pdf"
|
|
21
|
+
scenes = deck.scene_class_names
|
|
22
|
+
if not scenes:
|
|
23
|
+
raise ValueError(f"deck {deck.slug!r} has no scenes/entrypoints configured")
|
|
24
|
+
|
|
25
|
+
presentation_configs = get_scenes_presentation_config(
|
|
26
|
+
list(scenes),
|
|
27
|
+
media_dir / "slides",
|
|
28
|
+
)
|
|
29
|
+
PDF(presentation_configs=presentation_configs).convert_to( # pyright: ignore[reportCallIssue]
|
|
30
|
+
pdf_path
|
|
31
|
+
)
|
|
32
|
+
return pdf_path
|