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
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
"""Per-main-slide JPG thumbnails from the reconciled subsection MP4s.
|
|
2
|
+
|
|
3
|
+
Default rule: extract the **last frame** of the **second-to-last subsection**
|
|
4
|
+
(``subsections[-2]``). Configurable per main via
|
|
5
|
+
``deck.slides["Main Name"].thumbnail_section_index``. A literal path
|
|
6
|
+
override at ``deck.slides["Main Name"].thumbnail`` (relative to the deck
|
|
7
|
+
directory) wins over the extraction rule.
|
|
8
|
+
|
|
9
|
+
Content-addressable cache: file name = sha256 of source path + mtime + deck
|
|
10
|
+
slug, so re-rendering a subsection naturally invalidates its thumbnail.
|
|
11
|
+
|
|
12
|
+
Extraction tries the system ``ffmpeg`` CLI first (fastest), then falls back
|
|
13
|
+
to PyAV -- manim already depends on PyAV for its own concatenation pipeline,
|
|
14
|
+
so this fallback works without any extra system binaries (the typical reason
|
|
15
|
+
real previews showed "no preview yet" on Windows: manim runs via PyAV but
|
|
16
|
+
ffmpeg.exe is not on PATH, so the old code went straight to the placeholder).
|
|
17
|
+
The "no video" fallback ships an inline SVG so the placeholder is always a
|
|
18
|
+
valid image even when neither extractor can run.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import contextlib
|
|
22
|
+
import hashlib
|
|
23
|
+
import shutil
|
|
24
|
+
import subprocess
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
import av
|
|
28
|
+
import av.error
|
|
29
|
+
from PIL import Image
|
|
30
|
+
from PIL.Image import Resampling
|
|
31
|
+
|
|
32
|
+
from simplex.deck.config import DeckConfig
|
|
33
|
+
from simplex.manifest import DeckManifest, MainSlide, Subsection
|
|
34
|
+
|
|
35
|
+
DEFAULT_WIDTH = 480
|
|
36
|
+
DEFAULT_SECONDARY_WIDTH = 960
|
|
37
|
+
DEFAULT_GIF_WIDTH = 320
|
|
38
|
+
DEFAULT_GIF_FPS = 6
|
|
39
|
+
DEFAULT_GIF_MAX_FRAMES = 24
|
|
40
|
+
DEFAULT_GIF_COLORS = 64
|
|
41
|
+
PLACEHOLDER_NAME = "_placeholder.svg"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _key(video: Path, slug: str) -> str:
|
|
45
|
+
stat = video.stat()
|
|
46
|
+
raw = f"{slug}:{video.as_posix()}:{stat.st_mtime_ns}:{stat.st_size}"
|
|
47
|
+
return hashlib.sha256(raw.encode()).hexdigest()[:24]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _multi_video_key(videos: tuple[Path, ...], slug: str, suffix: str) -> str:
|
|
51
|
+
parts = [slug, suffix]
|
|
52
|
+
for video in videos:
|
|
53
|
+
stat = video.stat()
|
|
54
|
+
parts.append(f"{video.as_posix()}:{stat.st_mtime_ns}:{stat.st_size}")
|
|
55
|
+
return hashlib.sha256("|".join(parts).encode()).hexdigest()[:24]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _extract_frame(
|
|
59
|
+
video: Path,
|
|
60
|
+
dest: Path,
|
|
61
|
+
*,
|
|
62
|
+
width: int,
|
|
63
|
+
seek_from_end: bool = True,
|
|
64
|
+
) -> bool:
|
|
65
|
+
"""Extract one frame near the end (or start) of ``video`` to ``dest``.
|
|
66
|
+
|
|
67
|
+
Tries ``ffmpeg`` first; falls back to PyAV (bundled with manim) when the
|
|
68
|
+
CLI is missing. Returns ``True`` on success.
|
|
69
|
+
"""
|
|
70
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
if _try_ffmpeg(video, dest, width=width, seek_from_end=seek_from_end):
|
|
72
|
+
return True
|
|
73
|
+
return _try_pyav(video, dest, width=width, seek_from_end=seek_from_end)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _try_ffmpeg(
|
|
77
|
+
video: Path,
|
|
78
|
+
dest: Path,
|
|
79
|
+
*,
|
|
80
|
+
width: int,
|
|
81
|
+
seek_from_end: bool,
|
|
82
|
+
) -> bool:
|
|
83
|
+
"""Extract a frame via the ``ffmpeg`` CLI if it's on PATH."""
|
|
84
|
+
if shutil.which("ffmpeg") is None:
|
|
85
|
+
return False
|
|
86
|
+
seek = ["-sseof", "-0.1"] if seek_from_end else ["-ss", "0.1"]
|
|
87
|
+
args = [
|
|
88
|
+
"ffmpeg",
|
|
89
|
+
"-y",
|
|
90
|
+
"-loglevel",
|
|
91
|
+
"error",
|
|
92
|
+
*seek,
|
|
93
|
+
"-i",
|
|
94
|
+
str(video),
|
|
95
|
+
"-vframes",
|
|
96
|
+
"1",
|
|
97
|
+
"-vf",
|
|
98
|
+
f"scale={width}:-2",
|
|
99
|
+
"-q:v",
|
|
100
|
+
"2",
|
|
101
|
+
str(dest),
|
|
102
|
+
]
|
|
103
|
+
with contextlib.suppress(subprocess.SubprocessError, FileNotFoundError):
|
|
104
|
+
subprocess.run(args, check=True, timeout=30)
|
|
105
|
+
return dest.exists()
|
|
106
|
+
return False
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _try_pyav(
|
|
110
|
+
video: Path,
|
|
111
|
+
dest: Path,
|
|
112
|
+
*,
|
|
113
|
+
width: int,
|
|
114
|
+
seek_from_end: bool,
|
|
115
|
+
) -> bool:
|
|
116
|
+
"""Decode a representative frame with PyAV and save it as JPEG.
|
|
117
|
+
|
|
118
|
+
Walks every frame and keeps the last when ``seek_from_end`` is true; for
|
|
119
|
+
typical slide-length clips (<10 s) that's still fast and avoids the
|
|
120
|
+
container-specific seek edge cases (no-keyframe, B-frame ordering, etc.)
|
|
121
|
+
that bite when you try to seek directly to the tail.
|
|
122
|
+
"""
|
|
123
|
+
try:
|
|
124
|
+
with av.open(str(video)) as container:
|
|
125
|
+
stream = container.streams.video[0]
|
|
126
|
+
stream.thread_type = "AUTO"
|
|
127
|
+
chosen = None
|
|
128
|
+
for frame in container.decode(stream):
|
|
129
|
+
chosen = frame
|
|
130
|
+
if not seek_from_end:
|
|
131
|
+
break
|
|
132
|
+
if chosen is None:
|
|
133
|
+
return False
|
|
134
|
+
image = chosen.to_image()
|
|
135
|
+
except (av.error.FFmpegError, IndexError, OSError):
|
|
136
|
+
return False
|
|
137
|
+
src_w, src_h = image.size
|
|
138
|
+
if src_w <= 0 or src_h <= 0:
|
|
139
|
+
return False
|
|
140
|
+
target_h = max(1, round(src_h * width / src_w))
|
|
141
|
+
if (src_w, src_h) != (width, target_h):
|
|
142
|
+
image = image.resize((width, target_h), Resampling.LANCZOS)
|
|
143
|
+
image.save(str(dest), "JPEG", quality=85, optimize=True)
|
|
144
|
+
return dest.exists()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
_PLACEHOLDER_SVG = (
|
|
148
|
+
'<?xml version="1.0" encoding="UTF-8"?>'
|
|
149
|
+
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 270" '
|
|
150
|
+
'preserveAspectRatio="xMidYMid slice" role="img" '
|
|
151
|
+
'aria-label="No preview available">'
|
|
152
|
+
'<rect width="480" height="270" fill="#242424"/>'
|
|
153
|
+
'<text x="240" y="142" text-anchor="middle" fill="#8a8a8a" '
|
|
154
|
+
'font-family="system-ui, sans-serif" font-size="18">no preview yet</text>'
|
|
155
|
+
"</svg>"
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _placeholder(dest_dir: Path) -> Path:
|
|
160
|
+
"""Write a 16:9 SVG placeholder once per deck and return its path.
|
|
161
|
+
|
|
162
|
+
SVG works without ffmpeg and is a single inline string, so the file is
|
|
163
|
+
always a valid image regardless of the host's video-tool availability.
|
|
164
|
+
"""
|
|
165
|
+
placeholder = dest_dir / PLACEHOLDER_NAME
|
|
166
|
+
if placeholder.exists():
|
|
167
|
+
return placeholder
|
|
168
|
+
placeholder.parent.mkdir(parents=True, exist_ok=True)
|
|
169
|
+
placeholder.write_text(_PLACEHOLDER_SVG, encoding="utf-8")
|
|
170
|
+
return placeholder
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _pick_source(main: MainSlide, override_idx: int) -> Subsection | None:
|
|
174
|
+
"""Pick which subsection's frame to use for `main`'s thumbnail.
|
|
175
|
+
|
|
176
|
+
Returns the subsection at ``override_idx`` if it has a usable video,
|
|
177
|
+
otherwise walks the remaining subsections (last-to-first) so a missing
|
|
178
|
+
video at ``-1`` (e.g. the trailing skip-animations section Manim emits
|
|
179
|
+
when ``construct()`` ends without a final ``play()``) doesn't degrade
|
|
180
|
+
into the placeholder. Returns ``None`` only when the main has no
|
|
181
|
+
playable video at any index.
|
|
182
|
+
"""
|
|
183
|
+
subs = main.subsections
|
|
184
|
+
if not subs:
|
|
185
|
+
return None
|
|
186
|
+
try:
|
|
187
|
+
primary = subs[override_idx]
|
|
188
|
+
except IndexError:
|
|
189
|
+
primary = subs[-1]
|
|
190
|
+
if _has_video(primary):
|
|
191
|
+
return primary
|
|
192
|
+
# Fallback: scan from last to first, skipping the primary we already
|
|
193
|
+
# tried. The last sub usually carries the slide's final visual state
|
|
194
|
+
# (the most informative thumbnail), so we prefer it over earlier ones.
|
|
195
|
+
for sub in reversed(subs):
|
|
196
|
+
if sub is primary:
|
|
197
|
+
continue
|
|
198
|
+
if _has_video(sub):
|
|
199
|
+
return sub
|
|
200
|
+
return primary
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _has_video(sub: Subsection) -> bool:
|
|
204
|
+
return sub.video is not None and sub.video.exists()
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def generate(
|
|
208
|
+
deck: DeckConfig,
|
|
209
|
+
manifest: DeckManifest,
|
|
210
|
+
*,
|
|
211
|
+
site_deck_dir: Path,
|
|
212
|
+
cache_dir: Path,
|
|
213
|
+
) -> dict[int, Path]:
|
|
214
|
+
"""Generate one JPG per main slide. Returns ``{main.index: rel_path}``."""
|
|
215
|
+
thumbs_dir = site_deck_dir / "thumbs"
|
|
216
|
+
cache_root = cache_dir / "thumbnails" / deck.slug
|
|
217
|
+
thumbs_dir.mkdir(parents=True, exist_ok=True)
|
|
218
|
+
cache_root.mkdir(parents=True, exist_ok=True)
|
|
219
|
+
out: dict[int, Path] = {}
|
|
220
|
+
for main in manifest.main_slides:
|
|
221
|
+
out[main.index] = _one(main, deck, thumbs_dir, cache_root)
|
|
222
|
+
return out
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def generate_carousel_gif(
|
|
226
|
+
deck: DeckConfig,
|
|
227
|
+
manifest: DeckManifest,
|
|
228
|
+
*,
|
|
229
|
+
site_deck_dir: Path,
|
|
230
|
+
cache_dir: Path,
|
|
231
|
+
) -> Path | None:
|
|
232
|
+
"""Return a low-quality GIF preview for deck cards, if configured.
|
|
233
|
+
|
|
234
|
+
Resolution order:
|
|
235
|
+
1. ``[web] carousel_gif = "path/to/preview.gif"`` copies the user asset.
|
|
236
|
+
2. ``[web] carousel_gif_slides = [1, 3]`` samples rendered videos from
|
|
237
|
+
those 1-based main-slide indexes and writes a small cached GIF.
|
|
238
|
+
3. No configured preview returns ``None`` so the portal keeps the static
|
|
239
|
+
thumbnail only.
|
|
240
|
+
"""
|
|
241
|
+
previews_dir = site_deck_dir / "previews"
|
|
242
|
+
cache_root = cache_dir / "carousel-gifs" / deck.slug
|
|
243
|
+
if deck.web.carousel_gif is not None:
|
|
244
|
+
return _copy_gif_override(deck, previews_dir)
|
|
245
|
+
|
|
246
|
+
selected = set(deck.web.carousel_gif_slides)
|
|
247
|
+
if not selected:
|
|
248
|
+
return None
|
|
249
|
+
videos = tuple(
|
|
250
|
+
sub.video
|
|
251
|
+
for main in manifest.main_slides
|
|
252
|
+
if main.index in selected
|
|
253
|
+
for sub in [_pick_source(main, -1)]
|
|
254
|
+
if sub is not None and sub.video is not None and sub.video.exists()
|
|
255
|
+
)
|
|
256
|
+
if not videos:
|
|
257
|
+
return None
|
|
258
|
+
|
|
259
|
+
previews_dir.mkdir(parents=True, exist_ok=True)
|
|
260
|
+
cache_root.mkdir(parents=True, exist_ok=True)
|
|
261
|
+
key = _multi_video_key(videos, deck.slug, ",".join(str(i) for i in sorted(selected)))
|
|
262
|
+
cached = cache_root / f"{key}.gif"
|
|
263
|
+
dest = previews_dir / f"{key}.gif"
|
|
264
|
+
if cached.exists():
|
|
265
|
+
if not dest.exists():
|
|
266
|
+
shutil.copy2(cached, dest)
|
|
267
|
+
return dest.relative_to(site_deck_dir)
|
|
268
|
+
if _write_preview_gif(videos, dest):
|
|
269
|
+
shutil.copy2(dest, cached)
|
|
270
|
+
return dest.relative_to(site_deck_dir)
|
|
271
|
+
return None
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _copy_gif_override(deck: DeckConfig, previews_dir: Path) -> Path | None:
|
|
275
|
+
rel = deck.web.carousel_gif
|
|
276
|
+
if rel is None:
|
|
277
|
+
return None
|
|
278
|
+
src = deck.path / rel
|
|
279
|
+
if not src.exists() or src.suffix.lower() != ".gif":
|
|
280
|
+
return None
|
|
281
|
+
previews_dir.mkdir(parents=True, exist_ok=True)
|
|
282
|
+
key = _multi_video_key((src,), deck.slug, "override")
|
|
283
|
+
target = previews_dir / f"override_{key}.gif"
|
|
284
|
+
if not target.exists() or target.stat().st_mtime < src.stat().st_mtime:
|
|
285
|
+
shutil.copy2(src, target)
|
|
286
|
+
return target.relative_to(previews_dir.parent)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _write_preview_gif(videos: tuple[Path, ...], dest: Path) -> bool:
|
|
290
|
+
frames: list[Image.Image] = []
|
|
291
|
+
per_video = max(1, DEFAULT_GIF_MAX_FRAMES // max(1, len(videos)))
|
|
292
|
+
for video in videos:
|
|
293
|
+
frames.extend(_sample_gif_frames(video, max_frames=per_video, width=DEFAULT_GIF_WIDTH))
|
|
294
|
+
if len(frames) >= DEFAULT_GIF_MAX_FRAMES:
|
|
295
|
+
frames = frames[:DEFAULT_GIF_MAX_FRAMES]
|
|
296
|
+
break
|
|
297
|
+
if not frames:
|
|
298
|
+
return False
|
|
299
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
300
|
+
try:
|
|
301
|
+
frames[0].save(
|
|
302
|
+
str(dest),
|
|
303
|
+
save_all=True,
|
|
304
|
+
append_images=frames[1:],
|
|
305
|
+
duration=round(1000 / DEFAULT_GIF_FPS),
|
|
306
|
+
loop=0,
|
|
307
|
+
optimize=True,
|
|
308
|
+
)
|
|
309
|
+
except OSError:
|
|
310
|
+
return False
|
|
311
|
+
return dest.exists()
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _sample_gif_frames(video: Path, *, max_frames: int, width: int) -> list[Image.Image]:
|
|
315
|
+
out: list[Image.Image] = []
|
|
316
|
+
try:
|
|
317
|
+
with av.open(str(video)) as container:
|
|
318
|
+
stream = container.streams.video[0]
|
|
319
|
+
stream.thread_type = "AUTO"
|
|
320
|
+
rate = float(stream.average_rate or DEFAULT_GIF_FPS)
|
|
321
|
+
stride = max(1, round(rate / DEFAULT_GIF_FPS))
|
|
322
|
+
for i, frame in enumerate(container.decode(stream)):
|
|
323
|
+
if i % stride != 0:
|
|
324
|
+
continue
|
|
325
|
+
out.append(_gif_frame(frame.to_image(), width=width))
|
|
326
|
+
if len(out) >= max_frames:
|
|
327
|
+
break
|
|
328
|
+
except (av.error.FFmpegError, IndexError, OSError):
|
|
329
|
+
return []
|
|
330
|
+
return out
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _gif_frame(image: Image.Image, *, width: int) -> Image.Image:
|
|
334
|
+
src_w, src_h = image.size
|
|
335
|
+
if src_w > 0 and src_h > 0 and src_w != width:
|
|
336
|
+
target_h = max(1, round(src_h * width / src_w))
|
|
337
|
+
image = image.resize((width, target_h), Resampling.LANCZOS)
|
|
338
|
+
return image.convert("P", palette=Image.Palette.ADAPTIVE, colors=DEFAULT_GIF_COLORS)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _one(
|
|
342
|
+
main: MainSlide,
|
|
343
|
+
deck: DeckConfig,
|
|
344
|
+
thumbs_dir: Path,
|
|
345
|
+
cache_root: Path,
|
|
346
|
+
) -> Path:
|
|
347
|
+
# Literal path override?
|
|
348
|
+
override = deck.slides.get(main.name)
|
|
349
|
+
if override is not None and override.thumbnail is not None:
|
|
350
|
+
src = deck.path / override.thumbnail
|
|
351
|
+
if src.exists():
|
|
352
|
+
target = thumbs_dir / f"override_{main.index:04d}{src.suffix}"
|
|
353
|
+
shutil.copy2(src, target)
|
|
354
|
+
return target.relative_to(thumbs_dir.parent)
|
|
355
|
+
|
|
356
|
+
section_index = override.thumbnail_section_index if override is not None else -2
|
|
357
|
+
sub = _pick_source(main, section_index)
|
|
358
|
+
if sub is None or sub.video is None or not sub.video.exists():
|
|
359
|
+
return _placeholder(thumbs_dir).relative_to(thumbs_dir.parent)
|
|
360
|
+
|
|
361
|
+
key = _key(sub.video, deck.slug)
|
|
362
|
+
cached = cache_root / f"{key}.jpg"
|
|
363
|
+
dest = thumbs_dir / f"{key}.jpg"
|
|
364
|
+
if cached.exists():
|
|
365
|
+
if not dest.exists():
|
|
366
|
+
shutil.copy2(cached, dest)
|
|
367
|
+
return dest.relative_to(thumbs_dir.parent)
|
|
368
|
+
if _extract_frame(sub.video, dest, width=DEFAULT_WIDTH, seek_from_end=True):
|
|
369
|
+
shutil.copy2(dest, cached)
|
|
370
|
+
dest_2x = thumbs_dir / f"{key}@2x.jpg"
|
|
371
|
+
if _extract_frame(sub.video, dest_2x, width=DEFAULT_SECONDARY_WIDTH, seek_from_end=True):
|
|
372
|
+
shutil.copy2(dest_2x, cache_root / f"{key}@2x.jpg")
|
|
373
|
+
return dest.relative_to(thumbs_dir.parent)
|
|
374
|
+
return _placeholder(thumbs_dir).relative_to(thumbs_dir.parent)
|
simplex/slides/README.md
ADDED
simplex/theme/README.md
ADDED
simplex/web/README.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# web/
|
|
2
|
+
|
|
3
|
+
Static-site generator: home carousels, deck pages, viewer bridge, academic
|
|
4
|
+
notes pipeline (markdown + math + citations + Tufte sidenotes).
|
|
5
|
+
|
|
6
|
+
## Public surface
|
|
7
|
+
|
|
8
|
+
- `SiteConfig.load()` -- merge committed `site.toml` with env overrides
|
|
9
|
+
(`SIMPLEX_GA_TAG`, `SIMPLEX_BASE_URL`, `SIMPLEX_BRAND`, `SIMPLEX_PREVIEW`).
|
|
10
|
+
- `notes.render(notes_md_path, slide_count=..., bibliography=...)` --
|
|
11
|
+
markdown-it + dollarmath + footnotes + `[slide:N]` + `\cite{}` +
|
|
12
|
+
`\ref{}` -> Tufte-style academic HTML (serif body, Lato headings,
|
|
13
|
+
right-margin sidenotes, colour-coded theorem callouts, references
|
|
14
|
+
appendix, auto-fitted display math).
|
|
15
|
+
- `bibliography.Bibliography.load(refs_bib)` -- parse a `.bib`, assign
|
|
16
|
+
biblatex `alpha` labels (`[DHS11]`-style), render the cited subset.
|
|
17
|
+
- `callouts.transform(html)` -- rewrite `> **Theorem 3.1.** ...`
|
|
18
|
+
blockquotes as anchored `<aside class="callout callout-theorem"
|
|
19
|
+
id="theorem-3-1">` blocks; resolve `\ref{}` placeholders to the
|
|
20
|
+
display label.
|
|
21
|
+
- `builder.build(decks_dir, site_dir, *, render=True, site_cfg=None, only=(), scenes=(), watch=False)`
|
|
22
|
+
-- discover -> render -> pdf -> reconcile -> thumbnail -> notes ->
|
|
23
|
+
emit per-deck `slides.html` + page + per-section pages + home.
|
|
24
|
+
|
|
25
|
+
## Don't
|
|
26
|
+
|
|
27
|
+
- Don't bundle JS or load CDNs. Tailwind / KaTeX / RevealJS / Lato /
|
|
28
|
+
Merriweather are vendored under `static/` (see `static/README.md`).
|
|
29
|
+
- Don't hand-edit anything under `site/`. Edit templates / CSS instead.
|
|
30
|
+
- Don't import Jinja templates as Python modules. Loaded via
|
|
31
|
+
`PackageLoader("simplex.web", "templates")`.
|
|
32
|
+
- Don't write a manual "References" section in `notes.md`. Use
|
|
33
|
+
`\cite{key}` and ship a `refs.bib`; the renderer appends the list.
|
simplex/web/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Static-site generator: notes + builder + templates."""
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""BibTeX -> biblatex-alpha citation keys + HTML bibliography.
|
|
2
|
+
|
|
3
|
+
`bibtex.py` does the parsing; this module owns the data model and the
|
|
4
|
+
rendered output.
|
|
5
|
+
|
|
6
|
+
Public surface (see `simplex/web/README.md`):
|
|
7
|
+
|
|
8
|
+
- `Bibliography.parse(text)` / `.load(path)` -- read a `.bib` file.
|
|
9
|
+
- `Bibliography.get(key)` -- retrieve an entry, raises if missing.
|
|
10
|
+
- `Bibliography.has(key)` -- existence check (used by the citations plugin).
|
|
11
|
+
- `Bibliography.to_html(used)` -- ordered <ol> of the cited entries, in the
|
|
12
|
+
order they were first referenced. Pass an empty tuple to suppress.
|
|
13
|
+
|
|
14
|
+
Alpha labels follow `biblatex`'s `alpha` style:
|
|
15
|
+
|
|
16
|
+
1 author -> 3 letters of last name ("Hou00")
|
|
17
|
+
2-3 -> 1 letter of each last name ("KB15", "DHS11")
|
|
18
|
+
4+ -> 3 letters of first three + "+" ("ABG+23")
|
|
19
|
+
|
|
20
|
+
followed by the last two digits of the year (or `??` if unknown).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import re
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Self
|
|
26
|
+
|
|
27
|
+
from pydantic import BaseModel, ConfigDict
|
|
28
|
+
|
|
29
|
+
from simplex.web import bibtex
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Author(BaseModel):
|
|
33
|
+
"""A single author. `last` is what appears in alpha labels."""
|
|
34
|
+
|
|
35
|
+
model_config = ConfigDict(frozen=True)
|
|
36
|
+
|
|
37
|
+
last: str
|
|
38
|
+
first: str = ""
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def parse(cls, raw: str) -> Self:
|
|
42
|
+
"""Parse a BibTeX author token (`Last, First` or `First Last`)."""
|
|
43
|
+
raw = bibtex.unbrace(raw.strip())
|
|
44
|
+
if "," in raw:
|
|
45
|
+
last, first = raw.split(",", 1)
|
|
46
|
+
return cls(last=last.strip(), first=first.strip())
|
|
47
|
+
parts = raw.split()
|
|
48
|
+
if len(parts) == 1:
|
|
49
|
+
return cls(last=parts[0])
|
|
50
|
+
return cls(last=parts[-1], first=" ".join(parts[:-1]))
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def display(self) -> str:
|
|
54
|
+
"""`F. Last`-style initials for the bibliography list."""
|
|
55
|
+
if not self.first:
|
|
56
|
+
return self.last
|
|
57
|
+
initials = " ".join(f"{p[0]}." for p in self.first.split() if p)
|
|
58
|
+
return f"{initials} {self.last}".strip()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class BibEntry(BaseModel):
|
|
62
|
+
"""A single bibliography entry. The field bag stays a plain dict because
|
|
63
|
+
BibTeX's field set is open."""
|
|
64
|
+
|
|
65
|
+
model_config = ConfigDict(frozen=True)
|
|
66
|
+
|
|
67
|
+
key: str
|
|
68
|
+
entry_type: str
|
|
69
|
+
fields: dict[str, str]
|
|
70
|
+
authors: tuple[Author, ...]
|
|
71
|
+
year: int | None
|
|
72
|
+
alpha_key: str
|
|
73
|
+
|
|
74
|
+
@classmethod
|
|
75
|
+
def make(cls, key: str, entry_type: str, fields: dict[str, str]) -> Self:
|
|
76
|
+
authors = _parse_authors(fields.get("author") or fields.get("editor") or "")
|
|
77
|
+
year = _parse_year(fields.get("year", ""))
|
|
78
|
+
return cls(
|
|
79
|
+
key=key,
|
|
80
|
+
entry_type=entry_type.lower(),
|
|
81
|
+
fields=fields,
|
|
82
|
+
authors=authors,
|
|
83
|
+
year=year,
|
|
84
|
+
alpha_key=_alpha_key(authors, year),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def render_html(self) -> str:
|
|
88
|
+
"""Emit one <li> for the bibliography list.
|
|
89
|
+
|
|
90
|
+
The alpha-key marker (e.g. `[KB15]`) is rendered as an inline
|
|
91
|
+
`<span class="bib-marker">`, not a CSS counter, so the printed
|
|
92
|
+
bullet matches the inline `[KB15]` citation chip.
|
|
93
|
+
"""
|
|
94
|
+
parts: list[str] = []
|
|
95
|
+
if self.authors:
|
|
96
|
+
parts.append(_join_authors(self.authors))
|
|
97
|
+
if title := self.fields.get("title"):
|
|
98
|
+
# Wrap titles in quotes (academic convention). Joining with ", "
|
|
99
|
+
# yields the canonical `Authors, "Title," Venue, Year.` shape.
|
|
100
|
+
parts.append(f"“{_escape_html(bibtex.unbrace(title))}”")
|
|
101
|
+
if venue := self._venue_html():
|
|
102
|
+
parts.append(venue)
|
|
103
|
+
if self.year is not None:
|
|
104
|
+
parts.append(str(self.year))
|
|
105
|
+
body = ", ".join(parts)
|
|
106
|
+
if link := self._link_html():
|
|
107
|
+
body = f"{body} {link}"
|
|
108
|
+
marker = f'<span class="bib-marker">[{_escape_html(self.alpha_key)}]</span>'
|
|
109
|
+
return f'<li id="bib-{_escape_html(self.key)}" class="bib-entry">{marker} {body}.</li>'
|
|
110
|
+
|
|
111
|
+
def _venue_html(self) -> str:
|
|
112
|
+
for field in ("journal", "booktitle", "publisher", "school", "institution"):
|
|
113
|
+
if value := self.fields.get(field):
|
|
114
|
+
return f"<em>{_escape_html(bibtex.unbrace(value))}</em>"
|
|
115
|
+
return ""
|
|
116
|
+
|
|
117
|
+
def _link_html(self) -> str:
|
|
118
|
+
for field in ("doi", "url"):
|
|
119
|
+
value = self.fields.get(field)
|
|
120
|
+
if not value:
|
|
121
|
+
continue
|
|
122
|
+
value = bibtex.unbrace(value)
|
|
123
|
+
href = value if field == "url" else f"https://doi.org/{value}"
|
|
124
|
+
return (
|
|
125
|
+
f'<a class="bib-link" href="{_escape_html(href)}" '
|
|
126
|
+
f'rel="noopener" target="_blank">[link]</a>'
|
|
127
|
+
)
|
|
128
|
+
return ""
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class Bibliography(BaseModel):
|
|
132
|
+
"""Parsed .bib file. `entries` is keyed by the BibTeX cite key."""
|
|
133
|
+
|
|
134
|
+
entries: dict[str, BibEntry]
|
|
135
|
+
|
|
136
|
+
@classmethod
|
|
137
|
+
def parse(cls, text: str) -> Self:
|
|
138
|
+
"""Parse a BibTeX document. Duplicate keys keep the first definition;
|
|
139
|
+
the citations plugin marks unknown keys as stale."""
|
|
140
|
+
parsed: dict[str, BibEntry] = {}
|
|
141
|
+
for key, entry_type, fields in bibtex.parse(text):
|
|
142
|
+
if key in parsed:
|
|
143
|
+
continue
|
|
144
|
+
parsed[key] = BibEntry.make(key, entry_type, fields)
|
|
145
|
+
return cls(entries=parsed)
|
|
146
|
+
|
|
147
|
+
@classmethod
|
|
148
|
+
def load(cls, path: Path) -> Self:
|
|
149
|
+
return cls.parse(path.read_text(encoding="utf-8"))
|
|
150
|
+
|
|
151
|
+
@classmethod
|
|
152
|
+
def empty(cls) -> Self:
|
|
153
|
+
return cls(entries={})
|
|
154
|
+
|
|
155
|
+
def has(self, key: str) -> bool:
|
|
156
|
+
return key in self.entries
|
|
157
|
+
|
|
158
|
+
def get(self, key: str) -> BibEntry:
|
|
159
|
+
return self.entries[key]
|
|
160
|
+
|
|
161
|
+
def to_html(self, used: tuple[str, ...]) -> str:
|
|
162
|
+
"""Render an ordered list of the cited entries, in citation order."""
|
|
163
|
+
seen: set[str] = set()
|
|
164
|
+
ordered: list[BibEntry] = []
|
|
165
|
+
for key in used:
|
|
166
|
+
if key in seen or key not in self.entries:
|
|
167
|
+
continue
|
|
168
|
+
seen.add(key)
|
|
169
|
+
ordered.append(self.entries[key])
|
|
170
|
+
if not ordered:
|
|
171
|
+
return ""
|
|
172
|
+
items = "\n".join(e.render_html() for e in ordered)
|
|
173
|
+
return (
|
|
174
|
+
'<section class="bibliography" aria-labelledby="bib-heading">'
|
|
175
|
+
'<h2 id="bib-heading">References</h2>'
|
|
176
|
+
f'<ol class="bib-list">\n{items}\n</ol>'
|
|
177
|
+
"</section>"
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# ---------------------------------------------------------------------------
|
|
182
|
+
# Author / alpha-key helpers
|
|
183
|
+
# ---------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _parse_authors(raw: str) -> tuple[Author, ...]:
|
|
187
|
+
if not raw:
|
|
188
|
+
return ()
|
|
189
|
+
parts = re.split(r"\s+and\s+", raw, flags=re.IGNORECASE)
|
|
190
|
+
return tuple(Author.parse(p) for p in parts if p.strip())
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _parse_year(raw: str) -> int | None:
|
|
194
|
+
if not raw:
|
|
195
|
+
return None
|
|
196
|
+
match = re.search(r"\d{4}", raw)
|
|
197
|
+
return int(match.group(0)) if match else None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _alpha_key(authors: tuple[Author, ...], year: int | None) -> str:
|
|
201
|
+
"""biblatex-alpha label. Empty author list -> `Anon` prefix."""
|
|
202
|
+
suffix = f"{year % 100:02d}" if year is not None else "??"
|
|
203
|
+
if not authors:
|
|
204
|
+
return f"Anon{suffix}"
|
|
205
|
+
initials = [_first_letter(a.last) for a in authors]
|
|
206
|
+
match len(initials):
|
|
207
|
+
case 1:
|
|
208
|
+
prefix = (initials[0] + _initial_pad(authors[0].last))[:3]
|
|
209
|
+
case 2 | 3:
|
|
210
|
+
prefix = "".join(initials)
|
|
211
|
+
case _:
|
|
212
|
+
prefix = "".join(initials[:3]) + "+"
|
|
213
|
+
return f"{prefix}{suffix}"
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _first_letter(s: str) -> str:
|
|
217
|
+
for ch in s:
|
|
218
|
+
if ch.isalpha():
|
|
219
|
+
return ch.upper()
|
|
220
|
+
return "?"
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _initial_pad(last: str) -> str:
|
|
224
|
+
"""For single-author keys, take chars 2-3 of the last name (lowercased)."""
|
|
225
|
+
letters = [c for c in last if c.isalpha()]
|
|
226
|
+
return "".join(letters[1:3]).lower()
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _join_authors(authors: tuple[Author, ...]) -> str:
|
|
230
|
+
"""`A. Smith, B. Jones, and C. Lee`."""
|
|
231
|
+
names = [_escape_html(a.display) for a in authors]
|
|
232
|
+
match len(names):
|
|
233
|
+
case 0:
|
|
234
|
+
return ""
|
|
235
|
+
case 1:
|
|
236
|
+
return names[0]
|
|
237
|
+
case 2:
|
|
238
|
+
return f"{names[0]} and {names[1]}"
|
|
239
|
+
case _:
|
|
240
|
+
head = ", ".join(names[:-1])
|
|
241
|
+
return f"{head}, and {names[-1]}"
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
_ESCAPE = {"&": "&", "<": "<", ">": ">", '"': """}
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _escape_html(s: str) -> str:
|
|
248
|
+
return "".join(_ESCAPE.get(c, c) for c in s)
|