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 +76 -0
- pypresent/__main__.py +3 -0
- pypresent/assets/deck.css +157 -0
- pypresent/assets/deck.js +69 -0
- pypresent/blocks.py +192 -0
- pypresent/cli.py +175 -0
- pypresent/console.py +27 -0
- pypresent/context.py +41 -0
- pypresent/deck.py +402 -0
- pypresent/markup.py +69 -0
- pypresent/model.py +45 -0
- pypresent/nbio.py +193 -0
- pypresent/presentation.py +495 -0
- pypresent/render/__init__.py +6 -0
- pypresent/render/html.py +201 -0
- pypresent/render/markdown.py +117 -0
- pypresent/theme.py +270 -0
- pypresent/themes/dark.toml +23 -0
- pypresent/themes/office.toml +23 -0
- pypresent/themes/slate.toml +29 -0
- pypresent/themes/warm.toml +47 -0
- pypresent-0.1.0.dist-info/METADATA +235 -0
- pypresent-0.1.0.dist-info/RECORD +26 -0
- pypresent-0.1.0.dist-info/WHEEL +4 -0
- pypresent-0.1.0.dist-info/entry_points.txt +2 -0
- pypresent-0.1.0.dist-info/licenses/LICENSE +21 -0
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,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
|
+
}
|
pypresent/assets/deck.js
ADDED
|
@@ -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"")
|
|
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())
|