keyframes 0.1.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.
- keyframes/__init__.py +1 -0
- keyframes/build.py +102 -0
- keyframes/check.py +107 -0
- keyframes/cli.py +149 -0
- keyframes/deck.py +67 -0
- keyframes/export.py +106 -0
- keyframes/guide/__init__.py +25 -0
- keyframes/guide/components/keyframes-code.md +60 -0
- keyframes/guide/components/keyframes-excalidraw.md +148 -0
- keyframes/guide/components/keyframes-mujoco.md +76 -0
- keyframes/guide/components/keyframes-slider.md +65 -0
- keyframes/guide/components/keyframes-three.md +81 -0
- keyframes/guide/core.md +113 -0
- keyframes/patch.py +77 -0
- keyframes/render.py +32 -0
- keyframes/scaffold/.gitignore +3 -0
- keyframes/scaffold/CLAUDE.md +1 -0
- keyframes/scaffold/assets/deck.css +1 -0
- keyframes/scaffold/assets/figure.svg +1 -0
- keyframes/scaffold/deck.toml +10 -0
- keyframes/scaffold/references/.gitkeep +0 -0
- keyframes/scaffold/slides/01-title.html +4 -0
- keyframes/scaffold/slides/02-intro.html +17 -0
- keyframes/server.py +204 -0
- keyframes/static/components/keyframes-code.js +423 -0
- keyframes/static/components/keyframes-excalidraw.js +189 -0
- keyframes/static/components/keyframes-mujoco.js +515 -0
- keyframes/static/components/keyframes-slider.js +255 -0
- keyframes/static/components/keyframes-three.js +224 -0
- keyframes/static/editor/editor.css +228 -0
- keyframes/static/editor/editor.js +594 -0
- keyframes/static/editor/panel.css +127 -0
- keyframes/static/editor/panel.js +357 -0
- keyframes/static/index.html +23 -0
- keyframes/static/runtime/component.js +126 -0
- keyframes/static/runtime/live.js +31 -0
- keyframes/static/runtime/math.js +33 -0
- keyframes/static/runtime/presenter.js +91 -0
- keyframes/static/runtime/runtime.css +91 -0
- keyframes/static/runtime/runtime.js +215 -0
- keyframes/static/runtime/steps.js +15 -0
- keyframes/terminal.py +144 -0
- keyframes/themes/default/STYLE_GUIDE.md +73 -0
- keyframes/themes/default/assets/arc-top.webp +0 -0
- keyframes/themes/default/assets/orbit-ring.webp +0 -0
- keyframes/themes/default/assets/streaks-top.webp +0 -0
- keyframes/themes/default/assets/streaks-wide.webp +0 -0
- keyframes/themes/default/assets/sun-top.webp +0 -0
- keyframes/themes/default/assets/title-orbit.webp +0 -0
- keyframes/themes/default/layouts/content.html +16 -0
- keyframes/themes/default/layouts/figure.html +7 -0
- keyframes/themes/default/layouts/grid-2.html +19 -0
- keyframes/themes/default/layouts/grid-3x2.html +28 -0
- keyframes/themes/default/layouts/grid-4.html +44 -0
- keyframes/themes/default/layouts/media-side.html +16 -0
- keyframes/themes/default/layouts/section-arc.html +4 -0
- keyframes/themes/default/layouts/section-streaks.html +4 -0
- keyframes/themes/default/layouts/section.html +4 -0
- keyframes/themes/default/layouts/split-wide.html +7 -0
- keyframes/themes/default/layouts/split.html +13 -0
- keyframes/themes/default/layouts/statement.html +4 -0
- keyframes/themes/default/layouts/title.html +5 -0
- keyframes/themes/default/layouts/two-column.html +5 -0
- keyframes/themes/default/theme.css +330 -0
- keyframes/themes/minimal/STYLE_GUIDE.md +41 -0
- keyframes/themes/minimal/layouts/figure.html +7 -0
- keyframes/themes/minimal/layouts/section.html +4 -0
- keyframes/themes/minimal/layouts/statement.html +3 -0
- keyframes/themes/minimal/layouts/title-body.html +10 -0
- keyframes/themes/minimal/layouts/title.html +5 -0
- keyframes/themes/minimal/layouts/two-column.html +11 -0
- keyframes/themes/minimal/theme.css +196 -0
- keyframes/vendor.py +181 -0
- keyframes-0.1.0.dist-info/METADATA +119 -0
- keyframes-0.1.0.dist-info/RECORD +77 -0
- keyframes-0.1.0.dist-info/WHEEL +4 -0
- keyframes-0.1.0.dist-info/entry_points.txt +3 -0
keyframes/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
keyframes/build.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Static export. Vendor files are renamed so they work on any static host (no query strings,
|
|
2
|
+
JS/CSS always have an extension) and every reference to a renamed file is rewritten."""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import posixpath
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from urllib.parse import urlsplit
|
|
10
|
+
|
|
11
|
+
from .deck import STATIC_DIR, Deck, DeckError
|
|
12
|
+
from .render import render_index
|
|
13
|
+
from .vendor import VENDOR_PREFIX, _kind, vendor_import_map
|
|
14
|
+
|
|
15
|
+
_TOKEN = re.compile(r"""(?<=["'(])[^"'()\s]+(?=["')])""")
|
|
16
|
+
TEXT_SUFFIXES = (".js", ".mjs", ".css", ".html", ".json")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _vendor_entries(cache: Path):
|
|
20
|
+
for meta_path in cache.rglob("*.meta.json"):
|
|
21
|
+
meta = json.loads(meta_path.read_text())
|
|
22
|
+
file = meta_path.with_name(meta_path.name.removesuffix(".meta.json"))
|
|
23
|
+
parts = urlsplit(meta["url"])
|
|
24
|
+
key = f"{VENDOR_PREFIX}/{parts.netloc}{parts.path}" + (f"?{parts.query}" if parts.query else "")
|
|
25
|
+
name = file.relative_to(cache).as_posix()
|
|
26
|
+
if _kind(meta["type"]) == "js" and not name.endswith((".js", ".mjs")):
|
|
27
|
+
name += ".js"
|
|
28
|
+
if _kind(meta["type"]) == "css" and not name.endswith(".css"):
|
|
29
|
+
name += ".css"
|
|
30
|
+
yield key, f"{VENDOR_PREFIX}/{name}", file, meta["type"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def rewrite_refs(text: str, self_path: str, renames: dict[str, str]) -> str:
|
|
34
|
+
base = posixpath.dirname(self_path)
|
|
35
|
+
|
|
36
|
+
def sub(m: re.Match) -> str:
|
|
37
|
+
token = m.group(0)
|
|
38
|
+
path, sep, query = token.partition("?")
|
|
39
|
+
if not (path.startswith((".", VENDOR_PREFIX))):
|
|
40
|
+
return token
|
|
41
|
+
resolved = posixpath.normpath(posixpath.join(base, path)) + sep + query
|
|
42
|
+
target = renames.get(resolved)
|
|
43
|
+
if target is None:
|
|
44
|
+
return token
|
|
45
|
+
rel = posixpath.relpath(target, base or ".")
|
|
46
|
+
return rel if rel.startswith(".") else f"./{rel}"
|
|
47
|
+
|
|
48
|
+
return _TOKEN.sub(sub, text)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _copy_tree(src: Path, dst: Path, dst_prefix: str, renames: dict[str, str]):
|
|
52
|
+
for file in src.rglob("*"):
|
|
53
|
+
if not file.is_file() or file.name == "index.html" and src == STATIC_DIR:
|
|
54
|
+
continue
|
|
55
|
+
rel = file.relative_to(src).as_posix()
|
|
56
|
+
out = dst / rel
|
|
57
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
if file.suffix in TEXT_SUFFIXES:
|
|
59
|
+
out.write_text(rewrite_refs(file.read_text(), f"{dst_prefix}/{rel}", renames))
|
|
60
|
+
else:
|
|
61
|
+
shutil.copy2(file, out)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def warm_cache(deck: Deck) -> bool:
|
|
65
|
+
"""Load every slide once in a headless browser so all vendor files get cached."""
|
|
66
|
+
from .export import render_report
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
render_report(deck)
|
|
70
|
+
except DeckError:
|
|
71
|
+
return False
|
|
72
|
+
return True
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def build(deck: Deck, out: Path | None = None) -> Path:
|
|
76
|
+
out = (out or deck.root / "dist").resolve()
|
|
77
|
+
if out.exists():
|
|
78
|
+
shutil.rmtree(out)
|
|
79
|
+
out.mkdir(parents=True)
|
|
80
|
+
|
|
81
|
+
entries = list(_vendor_entries(deck.cache_dir / "vendor"))
|
|
82
|
+
renames = {key: name for key, name, _, _ in entries if key != name}
|
|
83
|
+
|
|
84
|
+
for _, name, file, content_type in entries:
|
|
85
|
+
target = out / name
|
|
86
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
87
|
+
if _kind(content_type):
|
|
88
|
+
target.write_text(rewrite_refs(file.read_text(), name, renames))
|
|
89
|
+
else:
|
|
90
|
+
shutil.copy2(file, target)
|
|
91
|
+
|
|
92
|
+
_copy_tree(STATIC_DIR, out / "_keyframes", "_keyframes", renames)
|
|
93
|
+
_copy_tree(deck.theme_dir, out / "_theme", "_theme", renames)
|
|
94
|
+
if (deck.root / "assets").is_dir():
|
|
95
|
+
_copy_tree(deck.root / "assets", out / "assets", "assets", renames)
|
|
96
|
+
|
|
97
|
+
import_map = {
|
|
98
|
+
name: rewrite_refs(f'"{path}"', "index.html", renames).strip('"')
|
|
99
|
+
for name, path in vendor_import_map(deck.imports).items()
|
|
100
|
+
}
|
|
101
|
+
(out / "index.html").write_text(render_index(deck, "present", import_map))
|
|
102
|
+
return out
|
keyframes/check.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from collections import Counter
|
|
3
|
+
from html.parser import HTMLParser
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .deck import STATIC_DIR, Deck
|
|
7
|
+
from .patch import find_block_tags
|
|
8
|
+
|
|
9
|
+
LOCAL_REF_ATTRS = {"src", "href", "model", "controller", "poster"}
|
|
10
|
+
COMPONENTS = {p.stem for p in (STATIC_DIR / "components").glob("keyframes-*.js")}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class _Collector(HTMLParser):
|
|
14
|
+
def __init__(self):
|
|
15
|
+
super().__init__()
|
|
16
|
+
self.tags: list[tuple[str, dict]] = []
|
|
17
|
+
self.depth = 0
|
|
18
|
+
self.roots: list[str] = []
|
|
19
|
+
|
|
20
|
+
def handle_starttag(self, tag, attrs):
|
|
21
|
+
if self.depth == 0:
|
|
22
|
+
self.roots.append(tag)
|
|
23
|
+
self.tags.append((tag, dict(attrs)))
|
|
24
|
+
if tag not in {"img", "br", "hr", "input", "meta", "link", "source", "wbr"}:
|
|
25
|
+
self.depth += 1
|
|
26
|
+
|
|
27
|
+
def handle_startendtag(self, tag, attrs):
|
|
28
|
+
if self.depth == 0:
|
|
29
|
+
self.roots.append(tag)
|
|
30
|
+
self.tags.append((tag, dict(attrs)))
|
|
31
|
+
|
|
32
|
+
def handle_endtag(self, tag):
|
|
33
|
+
self.depth = max(0, self.depth - 1)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _is_local(ref: str) -> bool:
|
|
37
|
+
return bool(ref) and not re.match(r"^([a-z]+:|//|#|data:|_vendor/|\$)", ref)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _check_slider(where: str, attrs: dict, ids: set[str]) -> list[str]:
|
|
41
|
+
target = attrs.get("for")
|
|
42
|
+
if not target:
|
|
43
|
+
return [f"{where}: <keyframes-slider> needs a for attribute"]
|
|
44
|
+
problems = []
|
|
45
|
+
if target not in ids:
|
|
46
|
+
problems.append(
|
|
47
|
+
f'{where}: <keyframes-slider for="{target}"> matches no data-block or id on this slide'
|
|
48
|
+
)
|
|
49
|
+
if "timeline" not in attrs and not attrs.get("param"):
|
|
50
|
+
problems.append(f'{where}: <keyframes-slider for="{target}"> needs param="..." or timeline')
|
|
51
|
+
return problems
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def check_slide(deck: Deck, path: Path) -> list[str]:
|
|
55
|
+
source = path.read_text()
|
|
56
|
+
problems = []
|
|
57
|
+
where = f"slides/{path.name}"
|
|
58
|
+
|
|
59
|
+
collector = _Collector()
|
|
60
|
+
collector.feed(source)
|
|
61
|
+
if collector.roots != ["section"]:
|
|
62
|
+
problems.append(f"{where}: must contain exactly one root <section>, found {collector.roots}")
|
|
63
|
+
|
|
64
|
+
blocks = find_block_tags(source)
|
|
65
|
+
for block_id, count in Counter(b.id for b in blocks).items():
|
|
66
|
+
if count > 1:
|
|
67
|
+
problems.append(f"{where}: duplicate data-block id {block_id!r}")
|
|
68
|
+
for block in blocks:
|
|
69
|
+
geometry = {k: block.attrs.get(f"data-{k}") for k in "xywh"}
|
|
70
|
+
missing = [k for k, v in geometry.items() if v is None]
|
|
71
|
+
if missing:
|
|
72
|
+
problems.append(f"{where}#{block.id}: missing data-{', data-'.join(missing)}")
|
|
73
|
+
continue
|
|
74
|
+
try:
|
|
75
|
+
x, y, w, h = (float(geometry[k]) for k in "xywh")
|
|
76
|
+
except ValueError:
|
|
77
|
+
problems.append(f"{where}#{block.id}: non-numeric geometry {geometry}")
|
|
78
|
+
continue
|
|
79
|
+
if x < 0 or y < 0 or x + w > deck.width or y + h > deck.height:
|
|
80
|
+
problems.append(f"{where}#{block.id}: extends outside the {deck.width}x{deck.height} canvas")
|
|
81
|
+
|
|
82
|
+
ids = {v for _, attrs in collector.tags for k, v in attrs.items() if k in ("id", "data-block") and v}
|
|
83
|
+
for tag, attrs in collector.tags:
|
|
84
|
+
if tag == "keyframes-slider":
|
|
85
|
+
problems += _check_slider(where, attrs, ids)
|
|
86
|
+
if tag.startswith("keyframes-") and tag not in COMPONENTS:
|
|
87
|
+
problems.append(f"{where}: unknown component <{tag}> (available: {sorted(COMPONENTS)})")
|
|
88
|
+
for attr in LOCAL_REF_ATTRS & attrs.keys():
|
|
89
|
+
ref = (attrs[attr] or "").split("?")[0].split("#")[0]
|
|
90
|
+
if _is_local(ref) and not (deck.root / ref).exists():
|
|
91
|
+
problems.append(f'{where}: <{tag} {attr}="{ref}"> file not found')
|
|
92
|
+
return problems
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def check_deck(deck: Deck, render: bool = False) -> list[str]:
|
|
96
|
+
paths = deck.slide_paths()
|
|
97
|
+
if not paths:
|
|
98
|
+
return [f"No slides found in {deck.slides_dir}"]
|
|
99
|
+
problems = [p for path in paths for p in check_slide(deck, path)]
|
|
100
|
+
if render:
|
|
101
|
+
from .export import render_report
|
|
102
|
+
|
|
103
|
+
for result in render_report(deck):
|
|
104
|
+
where = f"slides/{result.name}.html"
|
|
105
|
+
problems += [f"{where}: browser error: {e}" for e in result.errors]
|
|
106
|
+
problems += [f"{where}#{b}: content overflows its box" for b in result.overflowing]
|
|
107
|
+
return problems
|
keyframes/cli.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import shutil
|
|
3
|
+
import sys
|
|
4
|
+
import threading
|
|
5
|
+
import webbrowser
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .deck import PACKAGE_DIR, THEMES_DIR, Deck, DeckError
|
|
9
|
+
from .guide import GUIDE_DIR
|
|
10
|
+
|
|
11
|
+
SCAFFOLD_DIR = PACKAGE_DIR / "scaffold"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def cmd_init(args):
|
|
15
|
+
target = Path(args.path).resolve()
|
|
16
|
+
if (target / "deck.toml").exists():
|
|
17
|
+
sys.exit(f"{target} already has a deck.toml")
|
|
18
|
+
shutil.copytree(SCAFFOLD_DIR, target, dirs_exist_ok=True)
|
|
19
|
+
shutil.copy(GUIDE_DIR / "core.md", target / "AGENTS.md")
|
|
20
|
+
deck_toml = target / "deck.toml"
|
|
21
|
+
deck_toml.write_text(
|
|
22
|
+
deck_toml.read_text().replace("{{title}}", args.title or target.name).replace("{{theme}}", args.theme)
|
|
23
|
+
)
|
|
24
|
+
print(f"Created deck in {target}\nNext: cd {target} && keyframes dev")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def serve(args, mode: str):
|
|
28
|
+
import uvicorn
|
|
29
|
+
|
|
30
|
+
from .server import create_app, free_port
|
|
31
|
+
|
|
32
|
+
deck = Deck.load(Path(args.path))
|
|
33
|
+
port = free_port(args.port)
|
|
34
|
+
url = f"http://127.0.0.1:{port}/"
|
|
35
|
+
print(f"keyframes {mode} → {url}")
|
|
36
|
+
if mode == "dev":
|
|
37
|
+
print("Press E in the browser to toggle the layout editor, S for presenter view.")
|
|
38
|
+
if not args.no_open:
|
|
39
|
+
threading.Timer(0.8, lambda: webbrowser.open(url)).start()
|
|
40
|
+
uvicorn.run(create_app(deck, mode), host="127.0.0.1", port=port, log_level="warning")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def cmd_build(args):
|
|
44
|
+
from .build import build, warm_cache
|
|
45
|
+
|
|
46
|
+
deck = Deck.load(Path(args.path))
|
|
47
|
+
if not warm_cache(deck):
|
|
48
|
+
print("Warning: install keyframes[export] so build can preload every slide's dependencies.")
|
|
49
|
+
out = build(deck, Path(args.out) if args.out else None)
|
|
50
|
+
print(f"Built static deck → {out}")
|
|
51
|
+
print(f"Serve it with any static server, e.g. `python -m http.server -d {out}`")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def cmd_pdf(args):
|
|
55
|
+
from .export import export_pdf
|
|
56
|
+
|
|
57
|
+
deck = Deck.load(Path(args.path))
|
|
58
|
+
out = export_pdf(deck, Path(args.out) if args.out else deck.root / f"{deck.root.name}.pdf")
|
|
59
|
+
print(f"Wrote {out}")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def cmd_shot(args):
|
|
63
|
+
from .export import screenshot_slides
|
|
64
|
+
|
|
65
|
+
deck = Deck.load(Path(args.path))
|
|
66
|
+
out_dir = Path(args.out) if args.out else deck.cache_dir / "shots"
|
|
67
|
+
for path in screenshot_slides(deck, args.slides, out_dir, scale=args.scale):
|
|
68
|
+
print(path)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def cmd_check(args):
|
|
72
|
+
from .check import check_deck
|
|
73
|
+
|
|
74
|
+
problems = check_deck(Deck.load(Path(args.path)), render=args.render)
|
|
75
|
+
for problem in problems:
|
|
76
|
+
print(problem)
|
|
77
|
+
if problems:
|
|
78
|
+
sys.exit(1)
|
|
79
|
+
print("No problems found.")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def cmd_guide(args):
|
|
83
|
+
from .guide import full_guide
|
|
84
|
+
|
|
85
|
+
print(full_guide(Path(args.path)))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def cmd_themes(args):
|
|
89
|
+
for theme in sorted(p.name for p in THEMES_DIR.iterdir() if p.is_dir()):
|
|
90
|
+
print(theme)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def cmd_eject_theme(args):
|
|
94
|
+
deck = Deck.load(Path(args.path))
|
|
95
|
+
target = deck.root / "theme"
|
|
96
|
+
if target.exists():
|
|
97
|
+
sys.exit(f"{target} already exists")
|
|
98
|
+
shutil.copytree(deck.theme_dir, target)
|
|
99
|
+
config = deck.root / "deck.toml"
|
|
100
|
+
config.write_text(config.read_text().replace(f'theme = "{deck.theme}"', 'theme = "./theme"'))
|
|
101
|
+
print(f"Copied theme to {target} and pointed deck.toml at it.")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
105
|
+
parser = argparse.ArgumentParser(prog="keyframes", description=__doc__)
|
|
106
|
+
sub = parser.add_subparsers(required=True)
|
|
107
|
+
|
|
108
|
+
def command(name, fn, help, path=True):
|
|
109
|
+
p = sub.add_parser(name, help=help)
|
|
110
|
+
if path:
|
|
111
|
+
p.add_argument("path", nargs="?", default=".", help="deck folder (default: .)")
|
|
112
|
+
p.set_defaults(fn=fn)
|
|
113
|
+
return p
|
|
114
|
+
|
|
115
|
+
p = command("init", cmd_init, "create a new deck")
|
|
116
|
+
p.add_argument("--title")
|
|
117
|
+
p.add_argument("--theme", default="default")
|
|
118
|
+
|
|
119
|
+
for name, mode, help in [
|
|
120
|
+
("dev", "dev", "live server with layout editor"),
|
|
121
|
+
("present", "present", "serve without editor"),
|
|
122
|
+
]:
|
|
123
|
+
p = command(name, lambda a, m=mode: serve(a, m), help)
|
|
124
|
+
p.add_argument("--port", type=int, default=8765)
|
|
125
|
+
p.add_argument("--no-open", action="store_true")
|
|
126
|
+
|
|
127
|
+
p = command("build", cmd_build, "export a static site")
|
|
128
|
+
p.add_argument("-o", "--out")
|
|
129
|
+
p = command("pdf", cmd_pdf, "export a PDF (requires keyframes[export])")
|
|
130
|
+
p.add_argument("-o", "--out")
|
|
131
|
+
p = command("shot", cmd_shot, "screenshot slides to PNG (requires keyframes[export])", path=False)
|
|
132
|
+
p.add_argument("-d", "--deck", dest="path", default=".", help="deck folder (default: .)")
|
|
133
|
+
p.add_argument("slides", nargs="*", help="slide numbers or names (default: all)")
|
|
134
|
+
p.add_argument("-o", "--out")
|
|
135
|
+
p.add_argument("--scale", type=float, default=0.5)
|
|
136
|
+
p = command("check", cmd_check, "lint slides; --render also loads them in a browser")
|
|
137
|
+
p.add_argument("--render", action="store_true")
|
|
138
|
+
command("guide", cmd_guide, "print the full authoring reference for LLMs")
|
|
139
|
+
command("themes", cmd_themes, "list built-in themes", path=False)
|
|
140
|
+
command("eject-theme", cmd_eject_theme, "copy the current theme into the deck to customize")
|
|
141
|
+
return parser
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def main():
|
|
145
|
+
args = build_parser().parse_args()
|
|
146
|
+
try:
|
|
147
|
+
args.fn(args)
|
|
148
|
+
except DeckError as e:
|
|
149
|
+
sys.exit(str(e))
|
keyframes/deck.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import tomllib
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
PACKAGE_DIR = Path(__file__).parent
|
|
6
|
+
STATIC_DIR = PACKAGE_DIR / "static"
|
|
7
|
+
THEMES_DIR = PACKAGE_DIR / "themes"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DeckError(Exception):
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Deck:
|
|
16
|
+
root: Path
|
|
17
|
+
title: str
|
|
18
|
+
theme: str
|
|
19
|
+
width: int = 1920
|
|
20
|
+
height: int = 1080
|
|
21
|
+
extra_head: list[str] = field(default_factory=list)
|
|
22
|
+
imports: dict[str, str] = field(default_factory=dict)
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def load(cls, root: Path) -> "Deck":
|
|
26
|
+
root = root.resolve()
|
|
27
|
+
config_path = root / "deck.toml"
|
|
28
|
+
if not config_path.exists():
|
|
29
|
+
raise DeckError(f"No deck.toml in {root}. Run `keyframes init` first.")
|
|
30
|
+
config = tomllib.loads(config_path.read_text())
|
|
31
|
+
width, height = config.get("size", [1920, 1080])
|
|
32
|
+
return cls(
|
|
33
|
+
root=root,
|
|
34
|
+
title=config.get("title", root.name),
|
|
35
|
+
theme=config.get("theme", "default"),
|
|
36
|
+
width=width,
|
|
37
|
+
height=height,
|
|
38
|
+
extra_head=config.get("head", []),
|
|
39
|
+
imports=config.get("imports", {}),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def slides_dir(self) -> Path:
|
|
44
|
+
return self.root / "slides"
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def cache_dir(self) -> Path:
|
|
48
|
+
return self.root / ".keyframes"
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def theme_dir(self) -> Path:
|
|
52
|
+
local = (self.root / self.theme).resolve()
|
|
53
|
+
if local.is_dir():
|
|
54
|
+
return local
|
|
55
|
+
builtin = THEMES_DIR / self.theme
|
|
56
|
+
if builtin.is_dir():
|
|
57
|
+
return builtin
|
|
58
|
+
raise DeckError(f"Theme {self.theme!r} not found locally or in built-in themes.")
|
|
59
|
+
|
|
60
|
+
def slide_paths(self) -> list[Path]:
|
|
61
|
+
return sorted(self.slides_dir.glob("*.html"))
|
|
62
|
+
|
|
63
|
+
def slide_path(self, name: str) -> Path:
|
|
64
|
+
path = (self.slides_dir / f"{name}.html").resolve()
|
|
65
|
+
if path.parent != self.slides_dir.resolve() or not path.exists():
|
|
66
|
+
raise DeckError(f"Unknown slide {name!r}")
|
|
67
|
+
return path
|
keyframes/export.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import contextlib
|
|
2
|
+
import io
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .deck import Deck, DeckError
|
|
7
|
+
from .server import background_server
|
|
8
|
+
|
|
9
|
+
READY_TIMEOUT_MS = 90_000
|
|
10
|
+
|
|
11
|
+
OVERFLOW_SCRIPT = """() => [...document.querySelectorAll('.keyframes-slide [data-block]')]
|
|
12
|
+
.filter(el => el.scrollHeight > el.clientHeight + 2 || el.scrollWidth > el.clientWidth + 2)
|
|
13
|
+
.filter(el => getComputedStyle(el).overflow === 'visible')
|
|
14
|
+
.map(el => el.dataset.block)"""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class SlideRender:
|
|
19
|
+
number: int
|
|
20
|
+
name: str
|
|
21
|
+
errors: list[str] = field(default_factory=list)
|
|
22
|
+
overflowing: list[str] = field(default_factory=list)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@contextlib.contextmanager
|
|
26
|
+
def browser():
|
|
27
|
+
try:
|
|
28
|
+
from playwright.sync_api import Error, sync_playwright
|
|
29
|
+
except ImportError as e:
|
|
30
|
+
raise DeckError("Exports need the extra: uv add 'keyframes[export]'") from e
|
|
31
|
+
with sync_playwright() as p:
|
|
32
|
+
try:
|
|
33
|
+
instance = p.chromium.launch(channel="chrome")
|
|
34
|
+
except Error:
|
|
35
|
+
try:
|
|
36
|
+
instance = p.chromium.launch()
|
|
37
|
+
except Error as e:
|
|
38
|
+
raise DeckError("No browser found. Run: uv run playwright install chromium") from e
|
|
39
|
+
yield instance
|
|
40
|
+
instance.close()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def resolve_slides(deck: Deck, selectors: list[str] | None) -> list[tuple[int, str]]:
|
|
44
|
+
names = [p.stem for p in deck.slide_paths()]
|
|
45
|
+
if not selectors:
|
|
46
|
+
return list(enumerate(names, start=1))
|
|
47
|
+
picked = []
|
|
48
|
+
for selector in selectors:
|
|
49
|
+
if selector.isdigit() and 1 <= int(selector) <= len(names):
|
|
50
|
+
picked.append((int(selector), names[int(selector) - 1]))
|
|
51
|
+
elif selector in names:
|
|
52
|
+
picked.append((names.index(selector) + 1, selector))
|
|
53
|
+
else:
|
|
54
|
+
raise DeckError(f"No slide {selector!r}")
|
|
55
|
+
return picked
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _render_slides(deck: Deck, slides, scale: float, on_page):
|
|
59
|
+
results = []
|
|
60
|
+
with background_server(deck) as url, browser() as chrome:
|
|
61
|
+
page = chrome.new_page(
|
|
62
|
+
viewport={"width": deck.width, "height": deck.height}, device_scale_factor=scale
|
|
63
|
+
)
|
|
64
|
+
page.on("console", lambda m: m.type == "error" and results[-1].errors.append(m.text))
|
|
65
|
+
page.on("pageerror", lambda e: results[-1].errors.append(str(e)))
|
|
66
|
+
for number, name in slides:
|
|
67
|
+
result = SlideRender(number, name)
|
|
68
|
+
results.append(result)
|
|
69
|
+
page.goto(f"{url}?print={number}")
|
|
70
|
+
page.wait_for_selector("html[data-ready='1']", timeout=READY_TIMEOUT_MS)
|
|
71
|
+
result.overflowing = page.evaluate(OVERFLOW_SCRIPT)
|
|
72
|
+
on_page(page, result)
|
|
73
|
+
return results
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def screenshot_slides(deck: Deck, selectors, out_dir: Path, scale: float = 0.5) -> list[Path]:
|
|
77
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
paths = []
|
|
79
|
+
|
|
80
|
+
def shoot(page, result: SlideRender):
|
|
81
|
+
path = out_dir / f"{result.name}.png"
|
|
82
|
+
page.screenshot(path=path)
|
|
83
|
+
paths.append(path)
|
|
84
|
+
|
|
85
|
+
_render_slides(deck, resolve_slides(deck, selectors), scale, shoot)
|
|
86
|
+
return paths
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def render_report(deck: Deck) -> list[SlideRender]:
|
|
90
|
+
return _render_slides(deck, resolve_slides(deck, None), 0.25, lambda page, result: None)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def export_pdf(deck: Deck, out: Path) -> Path:
|
|
94
|
+
from pypdf import PdfReader, PdfWriter
|
|
95
|
+
|
|
96
|
+
writer = PdfWriter()
|
|
97
|
+
|
|
98
|
+
def print_page(page, result: SlideRender):
|
|
99
|
+
pdf = page.pdf(width=f"{deck.width}px", height=f"{deck.height}px", print_background=True)
|
|
100
|
+
for pdf_page in PdfReader(io.BytesIO(pdf)).pages[:1]:
|
|
101
|
+
writer.add_page(pdf_page)
|
|
102
|
+
|
|
103
|
+
_render_slides(deck, resolve_slides(deck, None), 1, print_page)
|
|
104
|
+
with out.open("wb") as f:
|
|
105
|
+
writer.write(f)
|
|
106
|
+
return out
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from ..deck import THEMES_DIR, Deck, DeckError
|
|
4
|
+
|
|
5
|
+
GUIDE_DIR = Path(__file__).parent
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _theme_dir(deck_path: Path) -> Path:
|
|
9
|
+
try:
|
|
10
|
+
return Deck.load(deck_path).theme_dir
|
|
11
|
+
except DeckError:
|
|
12
|
+
return THEMES_DIR / "default"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def full_guide(deck_path: Path) -> str:
|
|
16
|
+
theme = _theme_dir(deck_path)
|
|
17
|
+
parts = [(GUIDE_DIR / "core.md").read_text(), "# Components\n"]
|
|
18
|
+
parts += [p.read_text() for p in sorted((GUIDE_DIR / "components").glob("*.md"))]
|
|
19
|
+
if (theme / "STYLE_GUIDE.md").exists():
|
|
20
|
+
parts.append((theme / "STYLE_GUIDE.md").read_text())
|
|
21
|
+
layouts = sorted((theme / "layouts").glob("*.html"))
|
|
22
|
+
if layouts:
|
|
23
|
+
parts.append("## Layout templates\n")
|
|
24
|
+
parts += [f"### {p.stem}\n\n```html\n{p.read_text().strip()}\n```\n" for p in layouts]
|
|
25
|
+
return "\n\n".join(parts)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# `<keyframes-code>`: editable, runnable code
|
|
2
|
+
|
|
3
|
+
A syntax-highlighted code block (CodeMirror 6) the presenter can edit live. With `run`, it gets a Run button and an output pane: JavaScript runs in a sandboxed iframe, Python runs in the browser via Pyodide.
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
Always put the source inside `<script type="text/plain">`. The HTML parser leaves it untouched, so `<`, `>`, `&`, and `<tag>`-like text need no escaping. Indent it however you like: common indentation and leading/trailing blank lines are stripped.
|
|
8
|
+
|
|
9
|
+
```html
|
|
10
|
+
<div data-block="demo" data-x="96" data-y="200" data-w="1060" data-h="800">
|
|
11
|
+
<keyframes-code lang="python" run line-numbers highlight="|4-5|8-10">
|
|
12
|
+
<script type="text/plain">
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
x = np.linspace(0, 1, 5)
|
|
16
|
+
if x.max() < 2:
|
|
17
|
+
print(x ** 2)
|
|
18
|
+
</script>
|
|
19
|
+
</keyframes-code>
|
|
20
|
+
</div>
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The element fills its block and draws its own card (surface background, border, rounded corners), so put it in a plain block with no `card` class or padding.
|
|
24
|
+
|
|
25
|
+
## Attributes
|
|
26
|
+
|
|
27
|
+
| Attribute | Default | Meaning |
|
|
28
|
+
|----------------|---------|---------|
|
|
29
|
+
| `lang` | `text` | `python`, `javascript`/`js`, `typescript`/`ts`, `jsx`, `tsx`, `cpp`/`c`, `rust`, `json`, `css`, `html`. Anything else is shown as plain, unhighlighted text. |
|
|
30
|
+
| `run` | off | Adds the Run button, Shift+Enter / Cmd+Enter, and the output pane. Only for `python` and `javascript`/`js`; other languages show an error. |
|
|
31
|
+
| `readonly` | off | Not editable. |
|
|
32
|
+
| `line-numbers` | off | Show the line-number gutter. |
|
|
33
|
+
| `font-size` | `26` | Code size in px (`22`) or any CSS length. The bar and output scale with it. |
|
|
34
|
+
| `highlight` | none | Line ranges per build step, separated by `\|`. See below. |
|
|
35
|
+
| `packages` | none | Extra Pyodide packages to load before running, e.g. `packages="numpy scipy"`. Usually unneeded: packages imported by the code are detected and loaded automatically. |
|
|
36
|
+
|
|
37
|
+
The output pane takes 30% of the height. Change it with a CSS variable: `<keyframes-code style="--keyframes-code-output: 40%">`.
|
|
38
|
+
|
|
39
|
+
## Build steps with `highlight`
|
|
40
|
+
|
|
41
|
+
`highlight="1-3|5|7-9,12"` gives one segment per build step: step 0 highlights lines 1-3, step 1 line 5, step 2 lines 7-9 and 12. Lines outside the segment are dimmed. An empty segment means no highlight, so `highlight="|4-5|8-10"` starts with the whole snippet visible. The component creates the extra build steps itself; you do not need `data-step` elements. If the slide has other `data-step` elements they share the same step counter (step `n` reveals both `data-step="n"` and highlight segment `n`). Steps past the last segment keep the last one. Line numbers refer to the original source, not to lines the presenter adds while editing.
|
|
42
|
+
|
|
43
|
+
## Running code
|
|
44
|
+
|
|
45
|
+
- **Python**: Pyodide is downloaded the first time any block on any slide is run (a few seconds; the output shows "Loading Python…"), then shared by all blocks. Each run gets a fresh namespace. `print` and stderr go to the output pane; if the last line is an expression, its value is printed too (like a REPL). Tracebacks are trimmed to your code. Packages from the Pyodide distribution (numpy, scipy, pandas, ...) are available; arbitrary pip packages and network or file access are not.
|
|
46
|
+
- **JavaScript**: the code body runs as an async function, so top-level `await` works. `console.log/info/debug` go to the output, `console.warn/error` and uncaught errors show in red. Objects are printed as JSON. It runs in a sandboxed iframe: no access to the deck, the DOM, or `import`. Each run starts a clean sandbox.
|
|
47
|
+
- Neither runtime has a timeout. An infinite loop freezes the tab.
|
|
48
|
+
|
|
49
|
+
## Presenting
|
|
50
|
+
|
|
51
|
+
- Click into the code to edit. While the editor has focus, arrow keys and Space edit the code instead of changing slides. Press Esc to leave the editor and get slide navigation back.
|
|
52
|
+
- Edits survive navigating away and back. Reloading the page resets them to the slide source.
|
|
53
|
+
- Print and PDF show the code statically with no highlight dimming. The output pane is hidden unless something was run.
|
|
54
|
+
|
|
55
|
+
## Gotchas
|
|
56
|
+
|
|
57
|
+
- The only thing you cannot write inside `<script type="text/plain">` is the literal `</script>`. Write `<\/script>`; the component turns it back into `</script>`.
|
|
58
|
+
- Without the `<script>` wrapper, the element's text content is used. That works for code with no `<` or `&`, but the HTML parser will eat anything that looks like a tag. Don't rely on it.
|
|
59
|
+
- Size the block for the code. 26px code is about 40px per line, so 15 lines need around 650px of editor height, plus about 30% more for the output pane when `run` is set. Long lines scroll horizontally rather than wrap. For longer snippets, lower `font-size` to 20-22.
|
|
60
|
+
- `$` inside code is safe: math rendering skips `keyframes-code`.
|