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.
Files changed (91) hide show
  1. simplex/README.md +32 -0
  2. simplex/cli/README.md +13 -0
  3. simplex/cli/__init__.py +5 -0
  4. simplex/cli/commands.py +384 -0
  5. simplex/deck/README.md +19 -0
  6. simplex/deck/__init__.py +7 -0
  7. simplex/deck/_template/assets/.gitkeep +0 -0
  8. simplex/deck/_template/assets/code/.gitkeep +0 -0
  9. simplex/deck/_template/assets/figures/.gitkeep +0 -0
  10. simplex/deck/_template/deck.toml +11 -0
  11. simplex/deck/_template/manim.cfg +3 -0
  12. simplex/deck/_template/notes.md +27 -0
  13. simplex/deck/_template/refs.bib +12 -0
  14. simplex/deck/_template/slides/__init__.py +7 -0
  15. simplex/deck/_template/slides/intro.py +21 -0
  16. simplex/deck/config.py +207 -0
  17. simplex/deck/registry.py +110 -0
  18. simplex/deck/scaffold.py +86 -0
  19. simplex/deck/section.py +40 -0
  20. simplex/engine/README.md +9 -0
  21. simplex/render/README.md +46 -0
  22. simplex/render/__init__.py +1 -0
  23. simplex/render/html.py +132 -0
  24. simplex/render/pdf.py +32 -0
  25. simplex/render/pptx.py +32 -0
  26. simplex/render/reconcile.py +350 -0
  27. simplex/render/runner.py +116 -0
  28. simplex/render/thumbnail.py +374 -0
  29. simplex/slides/README.md +9 -0
  30. simplex/slides/components/README.md +9 -0
  31. simplex/theme/README.md +9 -0
  32. simplex/web/README.md +33 -0
  33. simplex/web/__init__.py +1 -0
  34. simplex/web/bibliography.py +248 -0
  35. simplex/web/bibtex.py +129 -0
  36. simplex/web/builder.py +321 -0
  37. simplex/web/callouts.py +134 -0
  38. simplex/web/citations.py +118 -0
  39. simplex/web/equations.py +79 -0
  40. simplex/web/notes.py +135 -0
  41. simplex/web/refs.py +60 -0
  42. simplex/web/sidenotes.py +76 -0
  43. simplex/web/site_config.py +71 -0
  44. simplex/web/slide_ref.py +54 -0
  45. simplex/web/static/.gitkeep +0 -0
  46. simplex/web/static/README.md +23 -0
  47. simplex/web/static/fonts/lato/lato-latin-400-italic.woff2 +0 -0
  48. simplex/web/static/fonts/lato/lato-latin-400-normal.woff2 +0 -0
  49. simplex/web/static/fonts/lato/lato-latin-700-italic.woff2 +0 -0
  50. simplex/web/static/fonts/lato/lato-latin-700-normal.woff2 +0 -0
  51. simplex/web/static/fonts/lato/lato-latin-900-normal.woff2 +0 -0
  52. simplex/web/static/fonts/merriweather/merriweather-latin-400-italic.woff2 +0 -0
  53. simplex/web/static/fonts/merriweather/merriweather-latin-400-normal.woff2 +0 -0
  54. simplex/web/static/fonts/merriweather/merriweather-latin-700-italic.woff2 +0 -0
  55. simplex/web/static/fonts/merriweather/merriweather-latin-700-normal.woff2 +0 -0
  56. simplex/web/static/fonts/merriweather/merriweather-latin-900-normal.woff2 +0 -0
  57. simplex/web/static/htmx.min.js +1 -0
  58. simplex/web/static/katex/auto-render.min.js +1 -0
  59. simplex/web/static/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  60. simplex/web/static/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  61. simplex/web/static/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  62. simplex/web/static/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  63. simplex/web/static/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  64. simplex/web/static/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  65. simplex/web/static/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  66. simplex/web/static/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  67. simplex/web/static/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  68. simplex/web/static/katex/katex.min.css +1 -0
  69. simplex/web/static/katex/katex.min.js +1 -0
  70. simplex/web/static/lucide/README.md +7 -0
  71. simplex/web/static/lucide/lucide.min.js +12 -0
  72. simplex/web/static/notes.js +68 -0
  73. simplex/web/static/reveal.js/reset.css +30 -0
  74. simplex/web/static/reveal.js/reveal.css +8 -0
  75. simplex/web/static/reveal.js/reveal.js +9 -0
  76. simplex/web/static/simplex.css +1870 -0
  77. simplex/web/static/tailwind.js +64 -0
  78. simplex/web/static/viewer.js +428 -0
  79. simplex/web/templates/README.md +19 -0
  80. simplex/web/templates/_carousel.html +117 -0
  81. simplex/web/templates/base.html +110 -0
  82. simplex/web/templates/deck.html +149 -0
  83. simplex/web/templates/index.html +20 -0
  84. simplex/web/templates/revealjs.html.j2 +374 -0
  85. simplex/web/templates/section.html +74 -0
  86. simplex/web/vendor.py +148 -0
  87. simplex_web-0.2.0.dist-info/METADATA +166 -0
  88. simplex_web-0.2.0.dist-info/RECORD +91 -0
  89. simplex_web-0.2.0.dist-info/WHEEL +4 -0
  90. simplex_web-0.2.0.dist-info/entry_points.txt +2 -0
  91. 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)
@@ -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))
@@ -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
@@ -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)
@@ -0,0 +1,9 @@
1
+ # engine/
2
+
3
+ Namespace placeholder for `simplex.engine` in this repo.
4
+
5
+ The implementation lives in the `manim-simplex` distribution and is merged via PEP 420.
6
+
7
+ ## Don't
8
+
9
+ - Don't add code here; keep engine helpers in manim-simplex.
@@ -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