pypresent 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.
pypresent/__init__.py ADDED
@@ -0,0 +1,76 @@
1
+ """pypresent - the notebook *is* the slides, and this turns it into one HTML file.
2
+
3
+ A deck is declared once, in the first cell of the notebook that holds it::
4
+
5
+ from pypresent import Presentation, slide, code, result, figure
6
+
7
+ deck = Presentation(
8
+ slides='talk-slides.ipynb', # this notebook: nothing but slide() calls
9
+ source='talk.ipynb', # what code(), result() and figure() quote
10
+ output='out/talk.html',
11
+ title='A talk', lang='en', theme='office',
12
+ )
13
+
14
+ and every cell after it is a slide::
15
+
16
+ slide('''
17
+ ## What it does
18
+
19
+ - the code on the slide is the code that ran
20
+ - the number on the slide is the number it printed
21
+ ''', code('tokenize'), result('tokenize'))
22
+
23
+ ``code()``, ``result()`` and ``figure()`` are read out of the source notebook at
24
+ build time, so the listing, the number and the chart on a slide are the ones
25
+ that actually ran, and a name that stops matching is reported rather than going
26
+ quietly stale.
27
+
28
+ Then ``pypresent build`` runs the source notebook, runs this one, says what has
29
+ drifted, and writes one self-contained HTML file.
30
+
31
+ (A plain markdown file works as a source too, cut into slides by its headings
32
+ and ``---`` lines. It quotes nothing, so it is the lesser half of this.)
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ __version__ = "0.1.0"
38
+
39
+ from .blocks import MIME, SlideSpec, code, figure, image, md, notes, out, result, slide
40
+ from .context import active
41
+ from .deck import CONFIG_MIME, Deck, parse, parse_markdown, parse_notebook
42
+ from .markup import inline, trim_text
43
+ from .model import Slide
44
+ from .presentation import Presentation
45
+ from .render import render_deck, render_markdown, render_slide
46
+ from .theme import Theme
47
+ from .theme import available as available_themes
48
+
49
+ __all__ = [
50
+ "CONFIG_MIME",
51
+ "MIME",
52
+ "Deck",
53
+ "Presentation",
54
+ "Slide",
55
+ "SlideSpec",
56
+ "Theme",
57
+ "__version__",
58
+ "active",
59
+ "available_themes",
60
+ "code",
61
+ "figure",
62
+ "image",
63
+ "inline",
64
+ "md",
65
+ "notes",
66
+ "out",
67
+ "parse",
68
+ "parse_markdown",
69
+ "parse_notebook",
70
+ "render_deck",
71
+ "render_markdown",
72
+ "render_slide",
73
+ "result",
74
+ "slide",
75
+ "trim_text",
76
+ ]
pypresent/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,157 @@
1
+ /* The deck stylesheet. Every colour, size and font is a custom property that
2
+ the theme sets, so restyling a deck never means editing this file.
3
+
4
+ The stage is a fixed 16:9 box letterboxed on a backdrop, and everything on it
5
+ is sized in cqh (1cqh = 1% of the slide's height) so the slide scales like a
6
+ projected slide instead of reflowing like a web page. */
7
+
8
+ *,*::before,*::after{box-sizing:border-box}
9
+ *{margin:0;padding:0}
10
+ html,body{height:100%}
11
+ body{
12
+ display:flex; align-items:center; justify-content:center;
13
+ background:var(--pp-backdrop); font-family:var(--pp-font); overflow:hidden;
14
+ -webkit-font-smoothing:antialiased;
15
+ }
16
+ .deck{
17
+ position:relative; overflow:hidden; border:1px solid var(--pp-line);
18
+ width:min(96vw,163.6vh); height:min(54vw,92vh);
19
+ background:var(--pp-canvas); container-type:size;
20
+ box-shadow:var(--pp-stage-shadow);
21
+ }
22
+ .slide{position:absolute; inset:0; display:none; flex-direction:column;
23
+ padding:var(--pp-pad)}
24
+ .slide.active{display:flex}
25
+
26
+ /* ---- title ---- */
27
+ .title{font-size:var(--pp-title); line-height:1.1; font-weight:var(--pp-title-weight);
28
+ color:var(--pp-accent); letter-spacing:var(--pp-title-tracking)}
29
+ .title-rule{margin-top:1.6cqh; height:var(--pp-rule); width:100%;
30
+ background:linear-gradient(var(--pp-rule-dir),var(--pp-accent) 0 22%,var(--pp-line) 22%)}
31
+
32
+ /* ---- body ---- */
33
+ .body{flex:1; min-height:0; margin-top:2.6cqh; overflow:hidden}
34
+ .body.split{display:flex; gap:4cqh; align-items:flex-start}
35
+ .body.split > .col{min-width:0; max-height:100%; overflow:hidden}
36
+ .body.split .bullets li{max-width:none}
37
+ .body.split pre{margin-top:0}
38
+ .fit{transform-origin:top var(--pp-start)}
39
+
40
+ p.lead{font-size:var(--pp-lead); line-height:1.3; color:var(--pp-ink);
41
+ margin-bottom:1.6cqh; max-width:62ch}
42
+ p.subhead{font-size:var(--pp-subhead); line-height:1.2; color:var(--pp-accent2);
43
+ font-weight:700; margin:1.8cqh 0 1cqh}
44
+ ul.bullets,ol.bullets{list-style:none; margin-bottom:1.2cqh}
45
+ ul.bullets>li,ol.bullets>li{
46
+ position:relative; padding-inline-start:3.4cqh; margin-bottom:var(--pp-bullet-gap);
47
+ font-size:var(--pp-bullet); line-height:1.3; color:var(--pp-ink); max-width:54ch;
48
+ }
49
+ ul.bullets>li::before{content:var(--pp-bullet-glyph); position:absolute;
50
+ inset-inline-start:0; top:.05em; font-size:.55em; color:var(--pp-accent2); line-height:1.9}
51
+ ol.bullets{counter-reset:b}
52
+ ol.bullets>li::before{counter-increment:b; content:counter(b) "."; position:absolute;
53
+ inset-inline-start:0; color:var(--pp-accent2); font-weight:700; font-size:.8em; top:.15em}
54
+ ul.bullets.sub{margin:.9cqh 0 .4cqh}
55
+ ul.bullets.sub > li{font-size:.8em; margin-bottom:1.2cqh; padding-inline-start:2.6cqh}
56
+ ul.bullets.sub > li::before{content:var(--pp-subbullet-glyph); font-size:.9em; top:0}
57
+ li b,li strong,p strong{font-weight:700; color:var(--pp-accent)}
58
+ em{font-style:italic}
59
+ a{color:var(--pp-accent2)}
60
+ blockquote.quote{
61
+ margin:1.4cqh 0; padding-inline-start:1.6cqh;
62
+ border-inline-start:var(--pp-rule) solid var(--pp-accent2);
63
+ font-size:var(--pp-quote); line-height:1.28; color:var(--pp-accent);
64
+ font-weight:700; max-width:50ch;
65
+ }
66
+
67
+ /* ---- code and output: always left to right, because Python does not read rtl ---- */
68
+ code{font-family:var(--pp-mono); font-size:.9em; color:var(--pp-inline-ink);
69
+ background:var(--pp-soft); padding:0 .3em; border-radius:.3cqh;
70
+ direction:ltr; display:inline-block; unicode-bidi:isolate}
71
+ b code,strong code{color:var(--pp-accent)}
72
+ pre{
73
+ direction:ltr; text-align:left; unicode-bidi:isolate; white-space:pre-wrap;
74
+ overflow-wrap:anywhere; tab-size:2; margin:1.2cqh 0;
75
+ font-family:var(--pp-mono); font-size:var(--pp-code); line-height:1.38;
76
+ padding:1.4cqh 1.8cqh; background:var(--pp-code-bg); color:var(--pp-code-fg);
77
+ border:none; border-radius:var(--pp-radius);
78
+ }
79
+ pre code{background:none; padding:0; font-size:1em; display:block; color:inherit}
80
+ pre.output{background:var(--pp-out-bg); color:var(--pp-out-fg);
81
+ border:1px solid var(--pp-line)}
82
+
83
+ /* ---- pictures ---- */
84
+ figure.image{margin:1.4cqh auto; line-height:0; background:var(--pp-frame);
85
+ border:var(--pp-frame-width) solid var(--pp-frame); outline:1px solid var(--pp-line);
86
+ box-shadow:var(--pp-frame-shadow); width:fit-content; max-width:100%}
87
+ figure.image img{display:block; width:100%; height:auto; max-height:var(--pp-image-max);
88
+ object-fit:contain}
89
+
90
+ /* ---- tables ---- */
91
+ table{border-collapse:collapse; margin:1.2cqh 0; font-size:var(--pp-table); line-height:1.25}
92
+ th,td{border:1px solid var(--pp-line); padding:.7cqh 1.2cqh; text-align:start;
93
+ vertical-align:top; color:var(--pp-ink)}
94
+ thead th{background:var(--pp-accent2); color:var(--pp-head-fg); font-weight:700;
95
+ border-color:var(--pp-accent2)}
96
+ thead th code{background:rgba(255,255,255,.18); color:var(--pp-head-fg)}
97
+ tbody tr:nth-child(even){background:var(--pp-row-alt)}
98
+ .rawhtml{overflow:hidden}
99
+ .rawhtml table{direction:ltr; font-family:var(--pp-mono); font-size:calc(var(--pp-table) * .85)}
100
+ .rawhtml thead th{background:var(--pp-accent2)}
101
+
102
+ /* ---- footer ---- */
103
+ .footer{display:flex; justify-content:space-between; align-items:baseline;
104
+ font-size:var(--pp-footer); color:var(--pp-muted); padding-top:1.4cqh}
105
+ .footer .num{font-variant-numeric:tabular-nums; direction:ltr}
106
+ .slide.cover .footer{visibility:hidden}
107
+
108
+ /* ---- title slide ---- */
109
+ .slide.cover{align-items:center; justify-content:center; text-align:center}
110
+ .slide.cover .title{font-size:var(--pp-cover-title); font-weight:var(--pp-cover-weight)}
111
+ .slide.cover .cover-rule{width:34cqh; height:.4cqh; background:var(--pp-accent2);
112
+ margin:3.4cqh auto}
113
+ .slide.cover .body{flex:0 0 auto; margin-top:0; text-align:center}
114
+ .slide.cover .body p.lead{font-size:3.1cqh; color:var(--pp-muted); max-width:none;
115
+ margin-inline:auto}
116
+ .slide.cover ol.bullets,.slide.cover ul.bullets{display:inline-block; text-align:start}
117
+ .cover-band{position:absolute; inset-inline:0; bottom:0; height:2.4cqh;
118
+ background:linear-gradient(var(--pp-rule-dir),var(--pp-accent) 0 62%,var(--pp-accent2) 62%)}
119
+
120
+ /* ---- chrome, on the backdrop and never on the slide ---- */
121
+ .progress{position:fixed; top:0; inset-inline:0; height:3px; background:rgba(0,0,0,.10)}
122
+ .progress>i{display:block; height:100%; background:var(--pp-accent)}
123
+ .chrome{position:fixed; bottom:10px; inset-inline-end:16px; display:flex;
124
+ align-items:center; gap:8px; color:var(--pp-muted); font-size:12px}
125
+ .chrome .dots{display:flex; gap:4px}
126
+ .chrome .dots i{width:5px; height:5px; border-radius:3px; background:rgba(0,0,0,.18)}
127
+ .chrome .dots i.on{width:14px; background:var(--pp-accent)}
128
+ .hint{position:fixed; bottom:10px; inset-inline-start:16px; color:var(--pp-muted);
129
+ font-size:11px; opacity:.75}
130
+ .notes{display:none}
131
+
132
+ /* ---- motion: a fade and an appear, nothing more ---- */
133
+ .reveal{opacity:0; transform:translateY(1.4cqh);
134
+ transition:opacity var(--pp-motion) ease,transform var(--pp-motion) ease}
135
+ .slide.active .reveal{opacity:1; transform:none}
136
+ .slide.active .title{transition-delay:.05s}
137
+ .slide.active .title-rule{transition-delay:.15s}
138
+ .slide.active .body{transition-delay:.25s}
139
+ .slide li{opacity:0; transform:translateX(-1.2cqh);
140
+ transition:opacity .4s ease,transform .4s ease}
141
+ [dir="rtl"] .slide li{transform:translateX(1.2cqh)}
142
+ .slide.active li{opacity:1; transform:none}
143
+ .slide.active li:nth-child(1){transition-delay:.30s}
144
+ .slide.active li:nth-child(2){transition-delay:.45s}
145
+ .slide.active li:nth-child(3){transition-delay:.60s}
146
+ .slide.active li:nth-child(4){transition-delay:.75s}
147
+ .slide.active li:nth-child(5){transition-delay:.90s}
148
+ @media (prefers-reduced-motion:reduce){.reveal,.slide li{transition:none}}
149
+
150
+ @media print{
151
+ body{display:block; background:#fff; overflow:visible}
152
+ .deck{width:100%; height:100vh; box-shadow:none}
153
+ .slide{position:static; display:flex !important; page-break-after:always; height:100vh}
154
+ .fit{transform:none !important}
155
+ .reveal,.slide li{opacity:1 !important; transform:none !important}
156
+ .progress,.chrome,.hint{display:none}
157
+ }
@@ -0,0 +1,69 @@
1
+ const slides = [...document.querySelectorAll('.slide')];
2
+ const rtl = document.documentElement.dir === 'rtl';
3
+ const bar = document.querySelector('.progress > i');
4
+ const counter = document.querySelector('.chrome .num');
5
+ const dots = document.querySelector('.chrome .dots');
6
+ let idx = 0;
7
+
8
+ slides.forEach((_, i) => dots.insertAdjacentHTML('beforeend', `<i data-i="${i}"></i>`));
9
+
10
+ // each column is measured on its own - otherwise the taller one drags the
11
+ // shorter one down with it and both end up smaller than they need to be
12
+ function fit(slide) {
13
+ const body = slide.querySelector('.body');
14
+ if (!body) return;
15
+ const avail = body.clientHeight;
16
+ slide.querySelectorAll('.fit').forEach(box => {
17
+ box.style.transform = 'none';
18
+ const need = Math.max(box.scrollHeight, box.getBoundingClientRect().height);
19
+ box.style.transform = need > avail + 1 ? `scale(${Math.max(avail / need, 0.45)})` : 'none';
20
+ box.dataset.scale = need > avail + 1 ? (avail / need).toFixed(3) : '1';
21
+ });
22
+ }
23
+
24
+ // the slide is measured again after layout, fonts and the entry build settle
25
+ function refit() {
26
+ fit(slides[idx]);
27
+ requestAnimationFrame(() => fit(slides[idx]));
28
+ [120, 400, 900].forEach(t => setTimeout(() => fit(slides[idx]), t));
29
+ }
30
+
31
+ function show(n) {
32
+ idx = Math.max(0, Math.min(slides.length - 1, n));
33
+ slides.forEach((s, i) => s.classList.toggle('active', i === idx));
34
+ refit();
35
+ bar.style.width = ((idx + 1) / slides.length * 100) + '%';
36
+ counter.textContent = (idx + 1) + ' / ' + slides.length;
37
+ [...dots.children].forEach((d, i) => d.classList.toggle('on', i === idx));
38
+ if (location.hash !== '#' + (idx + 1)) history.replaceState(null, '', '#' + (idx + 1));
39
+ }
40
+
41
+ const next = () => show(idx + 1);
42
+ const prev = () => show(idx - 1);
43
+
44
+ addEventListener('keydown', e => {
45
+ const fwd = rtl ? 'ArrowLeft' : 'ArrowRight';
46
+ const back = rtl ? 'ArrowRight' : 'ArrowLeft';
47
+ if (e.key === fwd || e.key === 'PageDown' || e.key === ' ' || e.key === 'ArrowDown') { next(); e.preventDefault(); }
48
+ else if (e.key === back || e.key === 'PageUp' || e.key === 'ArrowUp') { prev(); e.preventDefault(); }
49
+ else if (e.key === 'Home') show(0);
50
+ else if (e.key === 'End') show(slides.length - 1);
51
+ else if (e.key === 'f' || e.key === 'F') document.documentElement.requestFullscreen?.();
52
+ });
53
+ dots.addEventListener('click', e => { if (e.target.dataset.i) show(+e.target.dataset.i); });
54
+
55
+ let touch = null;
56
+ addEventListener('touchstart', e => touch = e.changedTouches[0].clientX, {passive: true});
57
+ addEventListener('touchend', e => {
58
+ if (touch === null) return;
59
+ const dx = e.changedTouches[0].clientX - touch;
60
+ if (Math.abs(dx) > 50) (dx < 0) === !rtl ? next() : prev();
61
+ touch = null;
62
+ }, {passive: true});
63
+
64
+ addEventListener('resize', () => fit(slides[idx]));
65
+ addEventListener('load', () => fit(slides[idx]));
66
+ document.fonts?.ready.then(() => fit(slides[idx]));
67
+ document.querySelectorAll('img').forEach(im => im.complete || (im.onload = () => fit(slides[idx])));
68
+ addEventListener('hashchange', () => show((parseInt(location.hash.slice(1)) || 1) - 1));
69
+ show((parseInt(location.hash.slice(1)) || 1) - 1);
pypresent/blocks.py ADDED
@@ -0,0 +1,192 @@
1
+ """What a slide notebook writes: ``slide()`` and the blocks that go inside it.
2
+
3
+ A block is a plain dict, so a slide survives the round trip through a notebook
4
+ cell output as JSON and the deck can be rendered later with no kernel at all.
5
+
6
+ Three of them - :func:`code`, :func:`result` and :func:`figure` - do not hold
7
+ content but a reference to a cell of the *source* notebook, found by the
8
+ ``# slide: name`` comment that cell carries. Nothing is ever pasted twice, and
9
+ a reference that stops matching is reported by the build rather than going
10
+ quietly stale.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from . import nbio
16
+ from .context import active
17
+ from .markup import clean
18
+
19
+ #: A slide, carried in a cell output.
20
+ MIME = "application/x-slide+json"
21
+
22
+ LAYOUTS = ("stack", "split")
23
+
24
+
25
+ # --------------------------------------------------------------------------
26
+ # blocks that hold their own content
27
+ # --------------------------------------------------------------------------
28
+
29
+ def md(text: str) -> dict:
30
+ """A prose block. A bare string in a ``slide()`` call means this."""
31
+ return {"kind": "md", "text": clean(text)}
32
+
33
+
34
+ def out(text: str, trim: dict | None = None) -> dict:
35
+ """A result to show. ``trim={'width': …, 'head': …, 'tail': …}``."""
36
+ return {"kind": "out", "text": clean(text), "trim": trim or {}}
37
+
38
+
39
+ def image(path: str) -> dict:
40
+ """A picture, by path relative to the deck's base directory."""
41
+ return {"kind": "image", "path": path}
42
+
43
+
44
+ def notes(text: str) -> dict:
45
+ """Speaker notes: kept in the deck, never shown on the slide."""
46
+ return {"kind": "notes", "text": clean(text)}
47
+
48
+
49
+ # --------------------------------------------------------------------------
50
+ # blocks quoted from the source notebook
51
+ # --------------------------------------------------------------------------
52
+
53
+ def code(name: str, keep=(), drop=(), trim=(), lang: str = "python") -> dict:
54
+ """The listing from the source cell named `name`.
55
+
56
+ The source names the cell with a ``# slide: name`` comment and says nothing
57
+ else about the deck; `trim` says which of its lines the room does not need
58
+ to read, by what they contain - ``trim=['GENRES =', ('# balance', 'seen +=
59
+ 1')]`` - and `keep` / `drop` are line substrings for the rare case that is
60
+ not enough.
61
+ """
62
+ lecture = active().lecture
63
+ if lecture is None:
64
+ return {"kind": "code", "text": f"# no source to quote {name!r} from",
65
+ "lang": lang, "error": "this presentation declares no source notebook"}
66
+ cell, guessed = nbio.find_cell(lecture, name)
67
+ if cell is None:
68
+ return {"kind": "code", "text": f"# no source cell named {name!r}",
69
+ "lang": lang, "error": f"no source cell named {name!r}"}
70
+ text, error = nbio.cut(nbio.source(cell), trim)
71
+ block = {"kind": "code", "text": nbio.pick(text, keep, drop), "lang": lang}
72
+ if guessed:
73
+ error = f"{name!r} matched a line, not a `# slide:` name"
74
+ if error:
75
+ block["error"] = error
76
+ return block
77
+
78
+
79
+ def result(name: str, keep=(), drop=(), trim: dict | None = None) -> dict:
80
+ """What that same named source cell printed - so a number on a slide is real."""
81
+ lecture = active().lecture
82
+ if lecture is None:
83
+ return {"kind": "out", "text": f"?? {name}", "trim": {},
84
+ "error": "this presentation declares no source notebook"}
85
+ cell, guessed = nbio.find_cell(lecture, name)
86
+ if cell is None:
87
+ return {"kind": "out", "text": f"?? {name}", "trim": {},
88
+ "error": f"no source cell named {name!r}"}
89
+ block = out(nbio.pick(nbio.printed(cell), keep, drop), trim)
90
+ if guessed:
91
+ block["error"] = f"{name!r} matched a line, not a `# slide:` name"
92
+ return block
93
+
94
+
95
+ def figure(name: str, which: int = 0) -> dict:
96
+ """The picture that same named source cell drew.
97
+
98
+ A plot is code output the way a printed number is, so it is quoted by name
99
+ rather than saved next to the deck as a picture of its own: re-run the
100
+ source and the slide shows the new chart, with nothing to update by hand and
101
+ no file to go stale. `which` picks among several figures.
102
+
103
+ Only the name is stored; the image itself is read out of the source notebook
104
+ at render time, so neither notebook carries a second copy of the bytes.
105
+ """
106
+ block = {"kind": "figure", "name": name, "which": which}
107
+ lecture = active().lecture
108
+ if lecture is None:
109
+ block["error"] = "this presentation declares no source notebook"
110
+ return block
111
+ cell, guessed = nbio.find_cell(lecture, name)
112
+ if cell is None:
113
+ block["error"] = f"no source cell named {name!r}"
114
+ elif not nbio.figures(cell):
115
+ block["error"] = f"the source cell {name!r} drew no figure"
116
+ elif guessed:
117
+ block["error"] = f"{name!r} matched a line, not a `# slide:` name"
118
+ return block
119
+
120
+
121
+ # --------------------------------------------------------------------------
122
+ # the slide
123
+ # --------------------------------------------------------------------------
124
+
125
+ def _as_block(item) -> dict:
126
+ if isinstance(item, str):
127
+ return md(item)
128
+ if isinstance(item, dict) and "kind" in item:
129
+ return item
130
+ if isinstance(item, dict): # {"he": …, "en": …}
131
+ return {"kind": "md", **{k: clean(v) for k, v in item.items()}}
132
+ raise TypeError(f"a slide block is a string or a block(), not {type(item).__name__}")
133
+
134
+
135
+ class SlideSpec:
136
+ """What a cell returns: a slide, described.
137
+
138
+ Jupyter shows it as Markdown, so the cell reads like the slide it makes; the
139
+ deck reads the JSON beside it.
140
+ """
141
+
142
+ def __init__(self, *blocks, title: str = "", layout: str = "stack",
143
+ split: tuple[int, int] = (46, 54), notes: str = "") -> None:
144
+ if layout not in LAYOUTS:
145
+ raise ValueError(f"layout must be one of {LAYOUTS}, not {layout!r}")
146
+ self.blocks = [_as_block(b) for b in blocks if b is not None]
147
+ self.title = title
148
+ self.layout = layout
149
+ self.split = list(split)
150
+ self.notes = clean(notes)
151
+
152
+ def payload(self) -> dict:
153
+ return {"blocks": self.blocks, "title": self.title,
154
+ "layout": self.layout, "split": self.split, "notes": self.notes}
155
+
156
+ @property
157
+ def errors(self) -> list[str]:
158
+ """Every reference on this slide that stopped matching."""
159
+ return [b["error"] for b in self.blocks if b.get("error")]
160
+
161
+ def as_markdown(self, lang: str = "en") -> str:
162
+ parts = []
163
+ for b in self.blocks:
164
+ kind = b["kind"]
165
+ if kind == "md":
166
+ parts.append(b.get(lang) or b.get("text") or next(
167
+ (v for k, v in b.items() if k != "kind" and isinstance(v, str)), ""))
168
+ elif kind == "code":
169
+ parts.append(f"```{b.get('lang', '')}\n{b['text']}\n```")
170
+ elif kind == "out":
171
+ parts.append(f"```\n{b['text']}\n```")
172
+ elif kind == "image":
173
+ parts.append(f"![]({b['path']})")
174
+ elif kind == "figure":
175
+ parts.append(f"*(figure: {b['name']}, drawn by the source notebook)*")
176
+ return "\n\n".join(p for p in parts if p)
177
+
178
+ def _repr_mimebundle_(self, include=None, exclude=None) -> dict:
179
+ return {"text/markdown": self.as_markdown(), MIME: self.payload()}
180
+
181
+ def __repr__(self) -> str:
182
+ return self.as_markdown()
183
+
184
+
185
+ def slide(*blocks, **kw) -> SlideSpec:
186
+ """Declare the slide this cell is."""
187
+ return SlideSpec(*blocks, **kw)
188
+
189
+
190
+ def slide_payload(cell: dict) -> dict | None:
191
+ """The slide a cell declares, read back out of its stored outputs."""
192
+ return nbio.payload(cell, MIME)
pypresent/cli.py ADDED
@@ -0,0 +1,175 @@
1
+ """The `pypresent` command.
2
+
3
+ The deck declares itself, so a command names an action and, at most, the file to
4
+ act on. A flag always wins over what the declaration says.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ from . import __version__
14
+ from .presentation import Presentation
15
+ from .theme import Theme, available
16
+
17
+ SOURCES = ("*-slides.ipynb", "*.slides.md", "*-slides.md")
18
+
19
+
20
+ def find_deck(where: Path | None = None) -> Path | None:
21
+ """The deck to act on when the command line names none.
22
+
23
+ Exactly one slide file in the folder is unambiguous; more than one is not,
24
+ and saying so beats picking one.
25
+ """
26
+ where = where or Path.cwd()
27
+ found = sorted({p for pattern in SOURCES for p in where.glob(pattern)})
28
+ if len(found) == 1:
29
+ return found[0]
30
+ if not found:
31
+ print(f"no slide file in {where} - name the one you mean "
32
+ f"(looked for {', '.join(SOURCES)})", file=sys.stderr)
33
+ else:
34
+ print(f"{len(found)} slide files in {where} - name the one you mean: "
35
+ + ", ".join(f.name for f in found), file=sys.stderr)
36
+ return None
37
+
38
+
39
+ def load(args) -> Presentation | None:
40
+ named = getattr(args, "deck", None)
41
+ path = Path(named) if named else find_deck()
42
+ if path is None:
43
+ return None
44
+ if not path.exists():
45
+ print(f"no such file: {path}", file=sys.stderr)
46
+ return None
47
+ # a path typed at a shell means what the shell means by it; inside the deck
48
+ # a relative path is relative to the deck, which is a different thing
49
+ output = getattr(args, "output", None)
50
+ return Presentation.from_notebook(
51
+ path,
52
+ lang=getattr(args, "lang", None),
53
+ direction=getattr(args, "direction", None),
54
+ title=getattr(args, "title", None),
55
+ date=getattr(args, "date", None),
56
+ output=output.resolve() if output else None,
57
+ theme=getattr(args, "theme", None),
58
+ split_level=getattr(args, "split_level", None),
59
+ # only the flag being present is an instruction; its absence is not
60
+ images=False if getattr(args, "no_images", False) else None,
61
+ )
62
+
63
+
64
+ def parser() -> argparse.ArgumentParser:
65
+ p = argparse.ArgumentParser(
66
+ prog="pypresent",
67
+ description="Slides from a Jupyter notebook, as one self-contained HTML file.")
68
+ p.add_argument("--version", action="version", version=f"pypresent {__version__}")
69
+ sub = p.add_subparsers(dest="command")
70
+
71
+ def common(parser):
72
+ parser.add_argument("deck", nargs="?", default=None,
73
+ help="the slide notebook or markdown file "
74
+ "(default: the only one here)")
75
+ parser.add_argument("--lang", default=None, help="e.g. en, he")
76
+ parser.add_argument("--dir", dest="direction", choices=["ltr", "rtl"], default=None)
77
+ parser.add_argument("--theme", default=None,
78
+ help=f"a built-in ({', '.join(available())}) or a theme file")
79
+ parser.add_argument("--title", default=None)
80
+ parser.add_argument("--date", default=None)
81
+ parser.add_argument("--split-level", type=int, default=None,
82
+ help="markdown headings up to this level start a slide")
83
+ parser.add_argument("--no-images", action="store_true",
84
+ help="leave pictures out")
85
+ parser.add_argument("-o", "--output", type=Path, default=None)
86
+ return parser
87
+
88
+ # A full build runs the source notebook and then the slide notebook, in that
89
+ # order, so what the deck quotes is what the source last printed. Both flags
90
+ # take a run away; neither adds one.
91
+ b = common(sub.add_parser(
92
+ "build", help="run both notebooks, check the deck, render it"))
93
+ b.add_argument("--skip-source-run", action="store_true",
94
+ help="do not run the source notebook; quote what it last printed")
95
+ b.add_argument("--skip-slides-run", action="store_true",
96
+ help="do not run the slide notebook; check and render what is stored")
97
+ b.add_argument("-f", "--format", dest="fmt", choices=["html", "md"], default="html")
98
+
99
+ r = common(sub.add_parser("render", help="render the stored outputs, without executing"))
100
+ r.add_argument("-f", "--format", dest="fmt", choices=["html", "md"], default="html")
101
+
102
+ common(sub.add_parser("check", help="what has drifted, said and not corrected"))
103
+
104
+ # two flags, not four command names: what to write, and how to write it
105
+ e = common(sub.add_parser("export", help="the deck or the source notebook, as html or md"))
106
+ e.add_argument("-m", "--mode", choices=["slide", "nb"], default="slide",
107
+ help="the deck the slide file declares, or the source notebook "
108
+ "straight through nbconvert (default: slide)")
109
+ e.add_argument("-f", "--format", dest="fmt", choices=["html", "md"], default="html")
110
+ e.add_argument("--code", dest="code", action="store_true", default=None,
111
+ help="nbconvert: keep the source cells (the default for html)")
112
+ e.add_argument("--no-code", dest="code", action="store_false",
113
+ help="nbconvert: outputs only (the default for md)")
114
+ e.add_argument("--skip-source-run", action="store_true",
115
+ help="slide+html: do not run the source notebook")
116
+ e.add_argument("--skip-slides-run", action="store_true",
117
+ help="slide+html: do not run the slide notebook")
118
+ e.add_argument("--also", nargs="*", default=[], metavar="NOTEBOOK",
119
+ help="further notebooks to convert beside the one chosen")
120
+
121
+ t = sub.add_parser("themes", help="the built-in themes, and what is in one")
122
+ t.add_argument("name", nargs="?", default=None,
123
+ help="print this theme as a file you can edit and pass to --theme")
124
+
125
+ return p
126
+
127
+
128
+ def main(argv: list[str] | None = None) -> int:
129
+ p = parser()
130
+ args = p.parse_args(argv)
131
+ if args.command is None: # a bare run builds the deck here
132
+ args = p.parse_args(["build"])
133
+
134
+ if args.command == "themes":
135
+ if args.name:
136
+ try:
137
+ print(Theme.resolve(args.name).to_toml(), end="")
138
+ except KeyError as why:
139
+ print(why.args[0], file=sys.stderr)
140
+ return 1
141
+ return 0
142
+ for name in available():
143
+ theme = Theme.named(name)
144
+ print(f"{name:<8} {theme.accent} {(theme.css and 'custom css') or ''}".rstrip())
145
+ return 0
146
+
147
+ deck = load(args)
148
+ if deck is None:
149
+ return 1
150
+
151
+ if args.command == "export":
152
+ if args.mode == "slide":
153
+ if args.fmt == "html":
154
+ return deck.build(run_source=not args.skip_source_run,
155
+ run_slides=not args.skip_slides_run)
156
+ return deck.render("md")
157
+ if deck.source is None:
158
+ print("this presentation declares no source notebook", file=sys.stderr)
159
+ return 1
160
+ return deck.convert([deck.source] + [deck._at(n) for n in args.also],
161
+ args.fmt, code=args.code)
162
+ if args.command == "build":
163
+ return deck.build(run_source=not args.skip_source_run,
164
+ run_slides=not args.skip_slides_run, fmt=args.fmt)
165
+ if args.command == "render":
166
+ return deck.render(args.fmt)
167
+ if args.command == "check":
168
+ said = deck.check()
169
+ print(f"{said} thing(s) to look at" if said else "nothing to report")
170
+ return 1 if said else 0
171
+ return deck.render()
172
+
173
+
174
+ if __name__ == "__main__": # pragma: no cover
175
+ raise SystemExit(main())