inkflow 0.1.0.dev0__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.
- inkflow/__init__.py +31 -0
- inkflow/cli.py +266 -0
- inkflow/content.py +227 -0
- inkflow/export.py +123 -0
- inkflow/layout.py +279 -0
- inkflow/manifest.py +145 -0
- inkflow/markdown.py +158 -0
- inkflow/ns.py +9 -0
- inkflow/pdf.html +19 -0
- inkflow/pipeline.py +244 -0
- inkflow/presenter.css +196 -0
- inkflow/presenter.html +50 -0
- inkflow/presenter.js +400 -0
- inkflow/server.py +380 -0
- inkflow/theme/layouts/center.svg +3 -0
- inkflow/theme/layouts/cover.svg +5 -0
- inkflow/theme/layouts/default.svg +4 -0
- inkflow/theme/layouts/end.svg +4 -0
- inkflow/theme/layouts/fact.svg +4 -0
- inkflow/theme/layouts/media-left.svg +5 -0
- inkflow/theme/layouts/media-right.svg +5 -0
- inkflow/theme/layouts/quote.svg +4 -0
- inkflow/theme/layouts/section.svg +4 -0
- inkflow/theme/layouts/statement.svg +3 -0
- inkflow/theme/layouts/two-cols-header.svg +5 -0
- inkflow/theme/layouts/two-cols.svg +5 -0
- inkflow/theme/main.svg +3 -0
- inkflow/theme/numbered-main.svg +6 -0
- inkflow/theme/showcase/deck.py +18 -0
- inkflow/theme/showcase/slides/01-cover.md +7 -0
- inkflow/theme/showcase/slides/02-section.md +7 -0
- inkflow/theme/showcase/slides/03-default.md +11 -0
- inkflow/theme/showcase/slides/04-center.md +8 -0
- inkflow/theme/showcase/slides/05-two-cols.md +17 -0
- inkflow/theme/showcase/slides/06-two-cols-header.md +13 -0
- inkflow/theme/showcase/slides/07-fact.md +7 -0
- inkflow/theme/showcase/slides/08-quote.md +7 -0
- inkflow/theme/showcase/slides/09-statement.md +3 -0
- inkflow/theme/showcase/slides/10-media-left.md +15 -0
- inkflow/theme/showcase/slides/11-media-right.md +16 -0
- inkflow/theme/showcase/slides/12-end.md +3 -0
- inkflow/theme/styles.css +55 -0
- inkflow/tui.py +128 -0
- inkflow-0.1.0.dev0.dist-info/METADATA +12 -0
- inkflow-0.1.0.dev0.dist-info/RECORD +48 -0
- inkflow-0.1.0.dev0.dist-info/WHEEL +4 -0
- inkflow-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- inkflow-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
inkflow/server.py
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
import errno
|
|
6
|
+
import importlib.resources
|
|
7
|
+
import importlib.util
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import signal
|
|
11
|
+
import sys
|
|
12
|
+
import termios
|
|
13
|
+
import time
|
|
14
|
+
import traceback
|
|
15
|
+
import tty
|
|
16
|
+
import webbrowser
|
|
17
|
+
from collections.abc import Awaitable, Callable
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import TypedDict, cast
|
|
20
|
+
|
|
21
|
+
from rich.console import Console
|
|
22
|
+
from rich.live import Live
|
|
23
|
+
from rich.text import Text
|
|
24
|
+
from watchfiles import awatch # pyright: ignore[reportUnknownVariableType]
|
|
25
|
+
from websockets.asyncio.server import ServerConnection
|
|
26
|
+
from websockets.asyncio.server import serve as ws_serve
|
|
27
|
+
|
|
28
|
+
from inkflow.layout import resolve_theme_dir
|
|
29
|
+
from inkflow.manifest import Deck
|
|
30
|
+
from inkflow.pipeline import process_deck, resolve_transitions
|
|
31
|
+
from inkflow.tui import LiveUI
|
|
32
|
+
|
|
33
|
+
# ── Shared mutable state ──────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class State(TypedDict):
|
|
37
|
+
slides: list[str]
|
|
38
|
+
transitions: list[dict[str, object]]
|
|
39
|
+
ws_clients: set[ServerConnection]
|
|
40
|
+
error: str | None
|
|
41
|
+
styles_css: str
|
|
42
|
+
dark_mode: bool
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
_state: State = {
|
|
46
|
+
"slides": [],
|
|
47
|
+
"transitions": [],
|
|
48
|
+
"ws_clients": set(),
|
|
49
|
+
"error": None,
|
|
50
|
+
"styles_css": "",
|
|
51
|
+
"dark_mode": True,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ── Deck loader ───────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def load_deck(deck_path: Path) -> Deck:
|
|
59
|
+
spec = importlib.util.spec_from_file_location("_inkflow_deck", deck_path)
|
|
60
|
+
if spec is None or spec.loader is None:
|
|
61
|
+
raise ImportError(f"Cannot load module from {deck_path}")
|
|
62
|
+
mod = importlib.util.module_from_spec(spec)
|
|
63
|
+
spec.loader.exec_module(mod)
|
|
64
|
+
if not hasattr(mod, "deck"):
|
|
65
|
+
raise AttributeError(
|
|
66
|
+
f"{deck_path} must define a module-level variable named 'deck'"
|
|
67
|
+
)
|
|
68
|
+
return cast(Deck, mod.deck)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ── Build pipeline ────────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def rebuild(deck_path: Path, ui: LiveUI) -> None:
|
|
75
|
+
ui.set_building()
|
|
76
|
+
|
|
77
|
+
async def _animate() -> None:
|
|
78
|
+
while True:
|
|
79
|
+
await asyncio.sleep(0.1)
|
|
80
|
+
ui.refresh()
|
|
81
|
+
|
|
82
|
+
spin = asyncio.create_task(_animate())
|
|
83
|
+
t0 = time.monotonic()
|
|
84
|
+
try:
|
|
85
|
+
deck = await asyncio.to_thread(load_deck, deck_path)
|
|
86
|
+
project_dir = deck_path.parent
|
|
87
|
+
slides = await asyncio.to_thread(process_deck, deck, project_dir)
|
|
88
|
+
transitions = resolve_transitions(deck)
|
|
89
|
+
styles_css = await asyncio.to_thread(load_styles, deck, project_dir)
|
|
90
|
+
_state["slides"] = slides
|
|
91
|
+
_state["transitions"] = transitions
|
|
92
|
+
_state["styles_css"] = styles_css
|
|
93
|
+
_state["dark_mode"] = deck.dark_mode
|
|
94
|
+
_state["error"] = None
|
|
95
|
+
ui.set_ok(len(slides), time.monotonic() - t0)
|
|
96
|
+
await broadcast(
|
|
97
|
+
json.dumps({"type": "update", "slides": slides, "transitions": transitions})
|
|
98
|
+
)
|
|
99
|
+
except Exception:
|
|
100
|
+
tb = traceback.format_exc()
|
|
101
|
+
_state["error"] = tb
|
|
102
|
+
ui.set_error(tb)
|
|
103
|
+
await broadcast(json.dumps({"type": "error", "message": tb}))
|
|
104
|
+
finally:
|
|
105
|
+
spin.cancel()
|
|
106
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
107
|
+
await spin
|
|
108
|
+
ui.refresh()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ── WebSocket broadcast ───────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
async def broadcast(msg: str) -> None:
|
|
115
|
+
dead: set[ServerConnection] = set()
|
|
116
|
+
for ws in list(_state["ws_clients"]):
|
|
117
|
+
try:
|
|
118
|
+
await ws.send(msg)
|
|
119
|
+
except Exception:
|
|
120
|
+
dead.add(ws)
|
|
121
|
+
_state["ws_clients"] -= dead
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ── WebSocket handler ─────────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def make_ws_handler(ui: LiveUI) -> Callable[[ServerConnection], Awaitable[None]]:
|
|
128
|
+
async def handler(websocket: ServerConnection) -> None:
|
|
129
|
+
_state["ws_clients"].add(websocket)
|
|
130
|
+
ui.refresh()
|
|
131
|
+
try:
|
|
132
|
+
await websocket.wait_closed()
|
|
133
|
+
finally:
|
|
134
|
+
_state["ws_clients"].discard(websocket)
|
|
135
|
+
ui.refresh()
|
|
136
|
+
|
|
137
|
+
return handler
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ── HTTP handler ──────────────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
_StreamHandler = Callable[[asyncio.StreamReader, asyncio.StreamWriter], Awaitable[None]]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def load_styles(deck: Deck, project_dir: Path) -> str:
|
|
146
|
+
pkg = importlib.resources.files("inkflow")
|
|
147
|
+
parts = [pkg.joinpath("theme", "styles.css").read_text(encoding="utf-8")]
|
|
148
|
+
|
|
149
|
+
if deck.theme is not None:
|
|
150
|
+
try:
|
|
151
|
+
theme_dir = resolve_theme_dir(deck.theme, project_dir)
|
|
152
|
+
theme_css = theme_dir / "styles.css"
|
|
153
|
+
if theme_css.exists():
|
|
154
|
+
parts.append(theme_css.read_text(encoding="utf-8"))
|
|
155
|
+
except ValueError:
|
|
156
|
+
pass
|
|
157
|
+
|
|
158
|
+
project_css = project_dir / "styles.css"
|
|
159
|
+
if project_css.exists():
|
|
160
|
+
parts.append(project_css.read_text(encoding="utf-8"))
|
|
161
|
+
|
|
162
|
+
return "\n".join(parts)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def build_html(state: State, ws_port: int | None) -> bytes:
|
|
166
|
+
pkg = importlib.resources.files("inkflow")
|
|
167
|
+
template = pkg.joinpath("presenter.html").read_text(encoding="utf-8")
|
|
168
|
+
css = pkg.joinpath("presenter.css").read_text(encoding="utf-8")
|
|
169
|
+
js = pkg.joinpath("presenter.js").read_text(encoding="utf-8")
|
|
170
|
+
data_theme = "" if state["dark_mode"] else "light"
|
|
171
|
+
ws_port_js = "null" if ws_port is None else str(ws_port)
|
|
172
|
+
html = (
|
|
173
|
+
template.replace("__CSS__", css)
|
|
174
|
+
.replace("__JS__", js)
|
|
175
|
+
.replace("__STYLES__", state["styles_css"])
|
|
176
|
+
.replace("__DATA_THEME__", data_theme)
|
|
177
|
+
.replace("__SLIDES_JSON__", json.dumps(state["slides"]))
|
|
178
|
+
.replace("__TRANSITIONS_JSON__", json.dumps(state["transitions"]))
|
|
179
|
+
.replace("__WS_PORT__", ws_port_js)
|
|
180
|
+
.replace("__ERROR_JSON__", json.dumps(state["error"]))
|
|
181
|
+
)
|
|
182
|
+
return html.encode("utf-8")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
_SERVED_SUFFIXES = {".mp4", ".webm", ".ogg", ".mov"}
|
|
186
|
+
_MIME_TYPES = {
|
|
187
|
+
".mp4": "video/mp4",
|
|
188
|
+
".webm": "video/webm",
|
|
189
|
+
".ogg": "video/ogg",
|
|
190
|
+
".mov": "video/quicktime",
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def make_http_handler(ws_port: int, project_dir: Path | None = None) -> _StreamHandler:
|
|
195
|
+
async def handler(
|
|
196
|
+
reader: asyncio.StreamReader, writer: asyncio.StreamWriter
|
|
197
|
+
) -> None:
|
|
198
|
+
try:
|
|
199
|
+
raw = await asyncio.wait_for(reader.read(4096), timeout=10)
|
|
200
|
+
request_line = raw.split(b"\r\n", 1)[0].decode(errors="replace")
|
|
201
|
+
parts = request_line.split(" ", 2)
|
|
202
|
+
request_path = parts[1] if len(parts) >= 2 else "/"
|
|
203
|
+
|
|
204
|
+
if project_dir is not None and request_path != "/":
|
|
205
|
+
asset_path = project_dir / request_path.lstrip("/")
|
|
206
|
+
suffix = asset_path.suffix.lower()
|
|
207
|
+
if asset_path.is_file() and suffix in _SERVED_SUFFIXES:
|
|
208
|
+
mime = _MIME_TYPES[suffix]
|
|
209
|
+
body = asset_path.read_bytes()
|
|
210
|
+
header = (
|
|
211
|
+
b"HTTP/1.1 200 OK\r\n"
|
|
212
|
+
+ f"Content-Type: {mime}\r\n".encode()
|
|
213
|
+
+ b"Cache-Control: no-store\r\n"
|
|
214
|
+
+ b"Connection: close\r\n"
|
|
215
|
+
+ b"Content-Length: "
|
|
216
|
+
+ str(len(body)).encode()
|
|
217
|
+
+ b"\r\n\r\n"
|
|
218
|
+
)
|
|
219
|
+
writer.write(header + body)
|
|
220
|
+
await writer.drain()
|
|
221
|
+
return
|
|
222
|
+
|
|
223
|
+
body = build_html(_state, ws_port)
|
|
224
|
+
header = (
|
|
225
|
+
b"HTTP/1.1 200 OK\r\n"
|
|
226
|
+
+ b"Content-Type: text/html; charset=utf-8\r\n"
|
|
227
|
+
+ b"Cache-Control: no-store\r\n"
|
|
228
|
+
+ b"Connection: close\r\n"
|
|
229
|
+
+ b"Content-Length: "
|
|
230
|
+
+ str(len(body)).encode()
|
|
231
|
+
+ b"\r\n\r\n"
|
|
232
|
+
)
|
|
233
|
+
writer.write(header + body)
|
|
234
|
+
|
|
235
|
+
await writer.drain()
|
|
236
|
+
except Exception:
|
|
237
|
+
pass
|
|
238
|
+
finally:
|
|
239
|
+
writer.close()
|
|
240
|
+
with contextlib.suppress(Exception):
|
|
241
|
+
await writer.wait_closed()
|
|
242
|
+
|
|
243
|
+
return handler
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
# ── File watcher ──────────────────────────────────────────────────────────────
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
async def _watch(deck_path: Path, ui: LiveUI, lock: asyncio.Lock) -> None:
|
|
250
|
+
async for _changes in awatch(str(deck_path.parent)):
|
|
251
|
+
if not lock.locked(): # skip if a rebuild is already in progress
|
|
252
|
+
async with lock:
|
|
253
|
+
await rebuild(deck_path, ui)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
# ── Keyboard handler ──────────────────────────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _open_browser(url: str) -> None:
|
|
260
|
+
# Redirect fd 1/2 to /dev/null so the browser process can't write startup
|
|
261
|
+
# noise to the terminal and corrupt the Rich Live cursor tracking.
|
|
262
|
+
devnull = os.open(os.devnull, os.O_WRONLY)
|
|
263
|
+
saved_out = os.dup(1)
|
|
264
|
+
saved_err = os.dup(2)
|
|
265
|
+
try:
|
|
266
|
+
os.dup2(devnull, 1)
|
|
267
|
+
os.dup2(devnull, 2)
|
|
268
|
+
webbrowser.open(url)
|
|
269
|
+
finally:
|
|
270
|
+
os.dup2(saved_out, 1)
|
|
271
|
+
os.dup2(saved_err, 2)
|
|
272
|
+
os.close(devnull)
|
|
273
|
+
os.close(saved_out)
|
|
274
|
+
os.close(saved_err)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
async def _read_keys(
|
|
278
|
+
deck_path: Path,
|
|
279
|
+
http_port: int,
|
|
280
|
+
ui: LiveUI,
|
|
281
|
+
lock: asyncio.Lock,
|
|
282
|
+
shutdown: asyncio.Event,
|
|
283
|
+
) -> None:
|
|
284
|
+
if not sys.stdin.isatty():
|
|
285
|
+
return
|
|
286
|
+
|
|
287
|
+
loop = asyncio.get_running_loop()
|
|
288
|
+
queue: asyncio.Queue[str] = asyncio.Queue()
|
|
289
|
+
|
|
290
|
+
def _on_stdin() -> None:
|
|
291
|
+
ch = sys.stdin.read(1)
|
|
292
|
+
loop.call_soon_threadsafe(queue.put_nowait, ch)
|
|
293
|
+
|
|
294
|
+
fd = sys.stdin.fileno()
|
|
295
|
+
old_settings = termios.tcgetattr(fd)
|
|
296
|
+
try:
|
|
297
|
+
tty.setcbreak(fd) # single-char reads, output processing (ONLCR) intact
|
|
298
|
+
loop.add_reader(fd, _on_stdin)
|
|
299
|
+
while True:
|
|
300
|
+
ch = await queue.get()
|
|
301
|
+
if ch in ("\x04", "q"): # Ctrl-D, q (Ctrl-C handled via SIGINT)
|
|
302
|
+
shutdown.set()
|
|
303
|
+
return
|
|
304
|
+
elif ch == "o":
|
|
305
|
+
_open_browser(f"http://localhost:{http_port}")
|
|
306
|
+
elif ch == "r":
|
|
307
|
+
async with lock:
|
|
308
|
+
await rebuild(deck_path, ui)
|
|
309
|
+
elif ch == "t":
|
|
310
|
+
ui.toggle_trace()
|
|
311
|
+
finally:
|
|
312
|
+
loop.remove_reader(fd)
|
|
313
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
# ── Public entry point ────────────────────────────────────────────────────────
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
async def serve(deck_path: Path, http_port: int, ws_port: int) -> None:
|
|
320
|
+
console = Console()
|
|
321
|
+
rebuild_lock = asyncio.Lock()
|
|
322
|
+
shutdown = asyncio.Event()
|
|
323
|
+
|
|
324
|
+
loop = asyncio.get_running_loop()
|
|
325
|
+
loop.add_signal_handler(signal.SIGINT, shutdown.set)
|
|
326
|
+
loop.add_signal_handler(signal.SIGTERM, shutdown.set)
|
|
327
|
+
|
|
328
|
+
try:
|
|
329
|
+
http_handler = make_http_handler(ws_port, deck_path.parent)
|
|
330
|
+
# Bind before the Live UI so port conflicts fail fast with a clean message
|
|
331
|
+
try:
|
|
332
|
+
http_server = await asyncio.start_server(
|
|
333
|
+
http_handler, "127.0.0.1", http_port
|
|
334
|
+
)
|
|
335
|
+
except OSError as e:
|
|
336
|
+
if e.errno == errno.EADDRINUSE:
|
|
337
|
+
msg = (
|
|
338
|
+
f"[red]error:[/red] port {http_port} is already in use."
|
|
339
|
+
f" Pass [dim]--port PORT[/dim] to serve on a different port."
|
|
340
|
+
)
|
|
341
|
+
console.print(msg)
|
|
342
|
+
return
|
|
343
|
+
raise
|
|
344
|
+
|
|
345
|
+
with Live(Text(""), console=console, auto_refresh=False) as live:
|
|
346
|
+
ui = LiveUI(
|
|
347
|
+
live,
|
|
348
|
+
http_port,
|
|
349
|
+
deck_path.parent,
|
|
350
|
+
get_clients=lambda: len(_state["ws_clients"]),
|
|
351
|
+
)
|
|
352
|
+
try:
|
|
353
|
+
async with (
|
|
354
|
+
http_server,
|
|
355
|
+
ws_serve(make_ws_handler(ui), "127.0.0.1", ws_port),
|
|
356
|
+
):
|
|
357
|
+
await rebuild(deck_path, ui)
|
|
358
|
+
tasks = [
|
|
359
|
+
asyncio.create_task(http_server.serve_forever()),
|
|
360
|
+
asyncio.create_task(_watch(deck_path, ui, rebuild_lock)),
|
|
361
|
+
asyncio.create_task(
|
|
362
|
+
_read_keys(deck_path, http_port, ui, rebuild_lock, shutdown)
|
|
363
|
+
),
|
|
364
|
+
]
|
|
365
|
+
await shutdown.wait()
|
|
366
|
+
for t in tasks:
|
|
367
|
+
t.cancel()
|
|
368
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
369
|
+
except OSError as e:
|
|
370
|
+
if e.errno == errno.EADDRINUSE:
|
|
371
|
+
msg = (
|
|
372
|
+
f"[red]error:[/red] port {ws_port} is already in use."
|
|
373
|
+
f" Pass [dim]--ws-port PORT[/dim] to use a different one."
|
|
374
|
+
)
|
|
375
|
+
console.print(msg)
|
|
376
|
+
else:
|
|
377
|
+
raise
|
|
378
|
+
finally:
|
|
379
|
+
loop.remove_signal_handler(signal.SIGINT)
|
|
380
|
+
loop.remove_signal_handler(signal.SIGTERM)
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" xmlns:inkflow="urn:inkflow" inkflow:parent="../main.svg" viewBox="0 0 1920 1080" width="1920" height="1080">
|
|
2
|
+
<rect id="zone-title" x="160" y="340" width="1600" height="220"/>
|
|
3
|
+
<rect id="zone-subtitle" x="160" y="580" width="1600" height="120"/>
|
|
4
|
+
<rect id="zone-note" x="160" y="730" width="1600" height="100"/>
|
|
5
|
+
</svg>
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" xmlns:inkflow="urn:inkflow" inkflow:parent="../numbered-main.svg" viewBox="0 0 1920 1080" width="1920" height="1080">
|
|
2
|
+
<rect id="zone-title" x="80" y="60" width="1760" height="120"/>
|
|
3
|
+
<rect id="zone-content" x="80" y="220" width="1760" height="780"/>
|
|
4
|
+
</svg>
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" xmlns:inkflow="urn:inkflow" inkflow:parent="../main.svg" viewBox="0 0 1920 1080" width="1920" height="1080">
|
|
2
|
+
<rect id="zone-title" x="160" y="380" width="1600" height="220"/>
|
|
3
|
+
<rect id="zone-subtitle" x="160" y="620" width="1600" height="120"/>
|
|
4
|
+
</svg>
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" xmlns:inkflow="urn:inkflow" inkflow:parent="../numbered-main.svg" viewBox="0 0 1920 1080" width="1920" height="1080">
|
|
2
|
+
<rect id="zone-fact" x="80" y="300" width="1760" height="300"/>
|
|
3
|
+
<rect id="zone-caption" x="80" y="650" width="1760" height="160"/>
|
|
4
|
+
</svg>
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" xmlns:inkflow="urn:inkflow" inkflow:parent="../numbered-main.svg" viewBox="0 0 1920 1080" width="1920" height="1080">
|
|
2
|
+
<rect id="zone-title" x="80" y="60" width="1760" height="120"/>
|
|
3
|
+
<rect id="zone-media" x="80" y="220" width="840" height="780"/>
|
|
4
|
+
<rect id="zone-content" x="1000" y="220" width="840" height="780"/>
|
|
5
|
+
</svg>
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" xmlns:inkflow="urn:inkflow" inkflow:parent="../numbered-main.svg" viewBox="0 0 1920 1080" width="1920" height="1080">
|
|
2
|
+
<rect id="zone-title" x="80" y="60" width="1760" height="120"/>
|
|
3
|
+
<rect id="zone-content" x="80" y="220" width="840" height="780"/>
|
|
4
|
+
<rect id="zone-media" x="1000" y="220" width="840" height="780"/>
|
|
5
|
+
</svg>
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" xmlns:inkflow="urn:inkflow" inkflow:parent="../numbered-main.svg" viewBox="0 0 1920 1080" width="1920" height="1080">
|
|
2
|
+
<rect id="zone-quote" x="160" y="300" width="1600" height="400"/>
|
|
3
|
+
<rect id="zone-attribution" x="160" y="730" width="1600" height="100"/>
|
|
4
|
+
</svg>
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" xmlns:inkflow="urn:inkflow" inkflow:parent="../main.svg" viewBox="0 0 1920 1080" width="1920" height="1080">
|
|
2
|
+
<rect id="zone-section-title" x="80" y="380" width="1760" height="220"/>
|
|
3
|
+
<rect id="zone-section-subtitle" x="80" y="620" width="1760" height="120"/>
|
|
4
|
+
</svg>
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" xmlns:inkflow="urn:inkflow" inkflow:parent="../numbered-main.svg" viewBox="0 0 1920 1080" width="1920" height="1080">
|
|
2
|
+
<rect id="zone-header" x="80" y="60" width="1760" height="120"/>
|
|
3
|
+
<rect id="zone-left" x="80" y="220" width="840" height="780"/>
|
|
4
|
+
<rect id="zone-right" x="1000" y="220" width="840" height="780"/>
|
|
5
|
+
</svg>
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" xmlns:inkflow="urn:inkflow" inkflow:parent="../numbered-main.svg" viewBox="0 0 1920 1080" width="1920" height="1080">
|
|
2
|
+
<rect id="zone-title" x="80" y="60" width="1760" height="120"/>
|
|
3
|
+
<rect id="zone-left" x="80" y="220" width="840" height="780"/>
|
|
4
|
+
<rect id="zone-right" x="1000" y="220" width="840" height="780"/>
|
|
5
|
+
</svg>
|
inkflow/theme/main.svg
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" xmlns:inkflow="urn:inkflow" inkflow:parent="./main.svg" viewBox="0 0 1920 1080" width="1920" height="1080" version="1.1" id="svg1">
|
|
2
|
+
<defs id="defs1"/>
|
|
3
|
+
<text id="zone-slide-number" x="1820" y="1055" class="theme-text-muted" text-anchor="end" font-size="28px" font-family="sans-serif">99</text>
|
|
4
|
+
<text id="zone-slide-total" x="1860" y="1055" class="theme-text-muted" font-size="28px" font-family="sans-serif">99</text>
|
|
5
|
+
<text id="text1" x="1832.489" y="1055" class="theme-text-muted" font-size="28px" font-family="sans-serif">/</text>
|
|
6
|
+
</svg>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from inkflow import Crossfade, Deck, MarkdownSlide
|
|
2
|
+
|
|
3
|
+
deck = Deck(transition=Crossfade())
|
|
4
|
+
|
|
5
|
+
deck.slides = [
|
|
6
|
+
MarkdownSlide("cover", content="01-cover"),
|
|
7
|
+
MarkdownSlide("section", content="02-section"),
|
|
8
|
+
MarkdownSlide("default", content="03-default", steps=True),
|
|
9
|
+
MarkdownSlide("center", content="04-center"),
|
|
10
|
+
MarkdownSlide("two-cols", content="05-two-cols"),
|
|
11
|
+
MarkdownSlide("two-cols-header", content="06-two-cols-header"),
|
|
12
|
+
MarkdownSlide("fact", content="07-fact"),
|
|
13
|
+
MarkdownSlide("quote", content="08-quote"),
|
|
14
|
+
MarkdownSlide("statement", content="09-statement"),
|
|
15
|
+
MarkdownSlide("media-left", content="10-media-left"),
|
|
16
|
+
MarkdownSlide("media-right", content="11-media-right"),
|
|
17
|
+
MarkdownSlide("end", content="12-end"),
|
|
18
|
+
]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# The `default` layout
|
|
2
|
+
|
|
3
|
+
Title at the top, content fills the rest.
|
|
4
|
+
|
|
5
|
+
- Supports **bold**, _italic_, and `inline code`
|
|
6
|
+
- Bullet lists can be stepped with `steps=True`
|
|
7
|
+
- Code blocks, blockquotes, and nested lists all work
|
|
8
|
+
|
|
9
|
+
::step::
|
|
10
|
+
|
|
11
|
+
Each `::step::` marker reveals the next chunk on click.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# `two-cols`
|
|
2
|
+
|
|
3
|
+
::left::
|
|
4
|
+
|
|
5
|
+
## Left column
|
|
6
|
+
|
|
7
|
+
- Independent content
|
|
8
|
+
- Each column scrolls on its own
|
|
9
|
+
- Use for comparisons or before/after
|
|
10
|
+
|
|
11
|
+
::right::
|
|
12
|
+
|
|
13
|
+
## Right column
|
|
14
|
+
|
|
15
|
+
- Counters or complements the left
|
|
16
|
+
- Both columns sit below a shared title
|
|
17
|
+
- Swap to `two-cols-header` for a wider header zone
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
::header::
|
|
2
|
+
|
|
3
|
+
`two-cols-header` — shared header, two equal columns
|
|
4
|
+
|
|
5
|
+
::left::
|
|
6
|
+
|
|
7
|
+
The header zone is wider than `two-cols` title zone and sits
|
|
8
|
+
above both columns, providing shared context.
|
|
9
|
+
|
|
10
|
+
::right::
|
|
11
|
+
|
|
12
|
+
Use this layout when the header is the primary label and the
|
|
13
|
+
columns carry parallel or contrasting detail underneath.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# `media-left`
|
|
2
|
+
|
|
3
|
+
::media::
|
|
4
|
+
|
|
5
|
+
_media zone_
|
|
6
|
+
|
|
7
|
+
Image or video fills the left half.
|
|
8
|
+
Pass `media=Media("file.jpg")` to `MarkdownSlide`.
|
|
9
|
+
|
|
10
|
+
::content::
|
|
11
|
+
|
|
12
|
+
Text content occupies the right half.
|
|
13
|
+
|
|
14
|
+
Use this layout when a visual element is the primary focus and
|
|
15
|
+
the text provides supporting context.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# `media-right`
|
|
2
|
+
|
|
3
|
+
::content::
|
|
4
|
+
|
|
5
|
+
Text content occupies the left half.
|
|
6
|
+
|
|
7
|
+
Use this layout when the narrative leads and the visual
|
|
8
|
+
confirms or illustrates — the natural reading direction
|
|
9
|
+
carries the eye from text to image.
|
|
10
|
+
|
|
11
|
+
::media::
|
|
12
|
+
|
|
13
|
+
_media zone_
|
|
14
|
+
|
|
15
|
+
Image or video fills the right half.
|
|
16
|
+
Pass `media=Media("file.jpg")` to `MarkdownSlide`.
|