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.
Files changed (48) hide show
  1. inkflow/__init__.py +31 -0
  2. inkflow/cli.py +266 -0
  3. inkflow/content.py +227 -0
  4. inkflow/export.py +123 -0
  5. inkflow/layout.py +279 -0
  6. inkflow/manifest.py +145 -0
  7. inkflow/markdown.py +158 -0
  8. inkflow/ns.py +9 -0
  9. inkflow/pdf.html +19 -0
  10. inkflow/pipeline.py +244 -0
  11. inkflow/presenter.css +196 -0
  12. inkflow/presenter.html +50 -0
  13. inkflow/presenter.js +400 -0
  14. inkflow/server.py +380 -0
  15. inkflow/theme/layouts/center.svg +3 -0
  16. inkflow/theme/layouts/cover.svg +5 -0
  17. inkflow/theme/layouts/default.svg +4 -0
  18. inkflow/theme/layouts/end.svg +4 -0
  19. inkflow/theme/layouts/fact.svg +4 -0
  20. inkflow/theme/layouts/media-left.svg +5 -0
  21. inkflow/theme/layouts/media-right.svg +5 -0
  22. inkflow/theme/layouts/quote.svg +4 -0
  23. inkflow/theme/layouts/section.svg +4 -0
  24. inkflow/theme/layouts/statement.svg +3 -0
  25. inkflow/theme/layouts/two-cols-header.svg +5 -0
  26. inkflow/theme/layouts/two-cols.svg +5 -0
  27. inkflow/theme/main.svg +3 -0
  28. inkflow/theme/numbered-main.svg +6 -0
  29. inkflow/theme/showcase/deck.py +18 -0
  30. inkflow/theme/showcase/slides/01-cover.md +7 -0
  31. inkflow/theme/showcase/slides/02-section.md +7 -0
  32. inkflow/theme/showcase/slides/03-default.md +11 -0
  33. inkflow/theme/showcase/slides/04-center.md +8 -0
  34. inkflow/theme/showcase/slides/05-two-cols.md +17 -0
  35. inkflow/theme/showcase/slides/06-two-cols-header.md +13 -0
  36. inkflow/theme/showcase/slides/07-fact.md +7 -0
  37. inkflow/theme/showcase/slides/08-quote.md +7 -0
  38. inkflow/theme/showcase/slides/09-statement.md +3 -0
  39. inkflow/theme/showcase/slides/10-media-left.md +15 -0
  40. inkflow/theme/showcase/slides/11-media-right.md +16 -0
  41. inkflow/theme/showcase/slides/12-end.md +3 -0
  42. inkflow/theme/styles.css +55 -0
  43. inkflow/tui.py +128 -0
  44. inkflow-0.1.0.dev0.dist-info/METADATA +12 -0
  45. inkflow-0.1.0.dev0.dist-info/RECORD +48 -0
  46. inkflow-0.1.0.dev0.dist-info/WHEEL +4 -0
  47. inkflow-0.1.0.dev0.dist-info/entry_points.txt +2 -0
  48. inkflow-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
inkflow/__init__.py ADDED
@@ -0,0 +1,31 @@
1
+ from inkflow.manifest import (
2
+ Animation,
3
+ Bounce,
4
+ Crossfade,
5
+ Cut,
6
+ Deck,
7
+ FadeIn,
8
+ FadeOut,
9
+ MarkdownSlide,
10
+ Media,
11
+ Morph,
12
+ Slide,
13
+ TextBox,
14
+ Transition,
15
+ )
16
+
17
+ __all__ = [
18
+ "Animation",
19
+ "Bounce",
20
+ "Crossfade",
21
+ "Cut",
22
+ "Deck",
23
+ "FadeIn",
24
+ "FadeOut",
25
+ "MarkdownSlide",
26
+ "Media",
27
+ "Morph",
28
+ "Slide",
29
+ "TextBox",
30
+ "Transition",
31
+ ]
inkflow/cli.py ADDED
@@ -0,0 +1,266 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import contextlib
5
+ import subprocess
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import cast
9
+
10
+ import click
11
+
12
+ from inkflow import ns
13
+ from inkflow.export import build_pdf, build_static_html
14
+ from inkflow.layout import (
15
+ inject_layout_layers,
16
+ is_layout_current,
17
+ resolve_chain,
18
+ resolve_parent_path,
19
+ )
20
+ from inkflow.manifest import MarkdownSlide
21
+ from inkflow.pipeline import clean_inkscape_svg, resolve_slide_src
22
+ from inkflow.server import load_deck
23
+ from inkflow.server import serve as _serve
24
+
25
+
26
+ @click.group()
27
+ def main() -> None:
28
+ """Terminal-native SVG presentation tool."""
29
+
30
+
31
+ @main.command()
32
+ @click.argument("deck", default="deck.py")
33
+ @click.option("--port", default=7777, show_default=True, help="HTTP port")
34
+ @click.option("--ws-port", default=7778, show_default=True, help="WebSocket port")
35
+ def serve(deck: str, port: int, ws_port: int) -> None:
36
+ """Start the presentation server."""
37
+ deck_path = Path(deck).resolve()
38
+ if not deck_path.exists():
39
+ raise click.ClickException(f"deck not found: {deck_path}")
40
+ with contextlib.suppress(KeyboardInterrupt):
41
+ asyncio.run(_serve(deck_path, port, ws_port))
42
+
43
+
44
+ @main.command()
45
+ @click.argument("files", nargs=-1, required=True, type=click.Path(path_type=Path))
46
+ @click.option(
47
+ "--stdout",
48
+ "to_stdout",
49
+ is_flag=True,
50
+ help="Write to stdout instead of modifying files in place",
51
+ )
52
+ def clean(files: tuple[Path, ...], to_stdout: bool) -> None:
53
+ """Strip Inkscape editor metadata from SVG files."""
54
+ errors = False
55
+ for p in files:
56
+ if not p.exists():
57
+ click.echo(f"[inkflow] clean: not found: {p}", err=True)
58
+ errors = True
59
+ continue
60
+ try:
61
+ cleaned = clean_inkscape_svg(p)
62
+ if to_stdout:
63
+ sys.stdout.write(cleaned)
64
+ else:
65
+ p.write_text(cleaned, encoding="utf-8")
66
+ click.echo(f"[inkflow] cleaned {p}")
67
+ except Exception as exc:
68
+ click.echo(f"[inkflow] clean: error processing {p}: {exc}", err=True)
69
+ errors = True
70
+ if errors:
71
+ sys.exit(1)
72
+
73
+
74
+ @main.command("setup-git")
75
+ def setup_git() -> None:
76
+ """Configure git hooks and SVG diff driver (run once after cloning)."""
77
+ steps = [
78
+ (
79
+ ["git", "config", "core.hooksPath", ".githooks"],
80
+ "pre-commit hook → .githooks/pre-commit",
81
+ ),
82
+ (
83
+ [
84
+ "git",
85
+ "config",
86
+ "diff.inkscape-svg.textconv",
87
+ "uv run inkflow clean --stdout",
88
+ ],
89
+ "SVG diff driver → strips Inkscape metadata before diffs",
90
+ ),
91
+ ]
92
+ for cmd, label in steps:
93
+ try:
94
+ _ = subprocess.run(cmd, check=True, capture_output=True)
95
+ click.echo(f"[inkflow] {label}")
96
+ except subprocess.CalledProcessError as exc:
97
+ stderr = cast(bytes | None, exc.stderr)
98
+ msg = stderr.decode().strip() if isinstance(stderr, bytes) else str(exc)
99
+ raise click.ClickException(f"setup-git failed: {msg}") from exc
100
+ hook = Path(".githooks/pre-commit")
101
+ if hook.exists():
102
+ hook.chmod(hook.stat().st_mode | 0o111)
103
+ click.echo("[inkflow] done — git is configured for this repository")
104
+
105
+
106
+ @main.command("inject-layout")
107
+ @click.argument("deck", default="deck.py", type=click.Path(path_type=Path))
108
+ @click.option(
109
+ "--check",
110
+ is_flag=True,
111
+ help="Report stale files without rewriting them. Exits 1 if any are stale.",
112
+ )
113
+ def inject_layout_cmd(deck: Path, check: bool) -> None:
114
+ """Refresh ancestor layout layers in all slide SVGs for Inkscape preview."""
115
+ deck_path = Path(deck).resolve()
116
+ if not deck_path.exists():
117
+ raise click.ClickException(f"deck not found: {deck_path}")
118
+
119
+ deck_obj = load_deck(deck_path)
120
+ project_dir = deck_path.parent
121
+ stale_found = False
122
+
123
+ for slide in deck_obj.slides:
124
+ if isinstance(slide, MarkdownSlide):
125
+ continue # MarkdownSlide has no per-slide SVG to inject into
126
+ svg_path = resolve_slide_src(slide.src, project_dir)
127
+ chain = resolve_chain(svg_path, project_dir, deck_obj.theme)
128
+ if not chain:
129
+ continue
130
+
131
+ if check:
132
+ if is_layout_current(svg_path, chain):
133
+ click.echo(f"[ok] {slide.src}")
134
+ else:
135
+ click.echo(f"[stale] {slide.src}")
136
+ stale_found = True
137
+ else:
138
+ changed = inject_layout_layers(svg_path, chain)
139
+ if changed:
140
+ click.echo(f"[injected] {slide.src}")
141
+ else:
142
+ click.echo(f"[up to date] {slide.src}")
143
+
144
+ if check and stale_found:
145
+ sys.exit(1)
146
+
147
+
148
+ @main.command("add")
149
+ @click.argument("parent")
150
+ @click.argument("output", type=click.Path(path_type=Path))
151
+ @click.option(
152
+ "--deck",
153
+ "deck_path",
154
+ default="deck.py",
155
+ type=click.Path(path_type=Path),
156
+ help="Path to deck.py (default: deck.py in cwd)",
157
+ )
158
+ def add_slide(parent: str, output: Path, deck_path: Path) -> None:
159
+ """Create a new slide SVG wired to a layout parent.
160
+
161
+ PARENT is a layout name or inkflow:parent string:
162
+ bare name (three-level search), 'local:foo', 'theme:foo', 'builtin:foo',
163
+ or a relative path. OUTPUT is the path for the new SVG file.
164
+ """
165
+ from lxml import etree as _etree
166
+
167
+ resolved_deck = Path(deck_path).resolve()
168
+ if not resolved_deck.exists():
169
+ raise click.ClickException(f"deck not found: {resolved_deck}")
170
+
171
+ deck_obj = load_deck(resolved_deck)
172
+ project_dir = resolved_deck.parent
173
+ output_path = Path(output).resolve()
174
+
175
+ if output_path.exists():
176
+ raise click.ClickException(f"file already exists: {output_path}")
177
+
178
+ output_path.parent.mkdir(parents=True, exist_ok=True)
179
+
180
+ # Resolve parent to get viewBox from the parent SVG if available.
181
+ try:
182
+ parent_abs = resolve_parent_path(
183
+ parent, output_path, project_dir, deck_obj.theme
184
+ )
185
+ except ValueError as exc:
186
+ raise click.ClickException(str(exc)) from exc
187
+
188
+ view_box = "0 0 1920 1080"
189
+ width = "1920"
190
+ height = "1080"
191
+ if parent_abs.exists():
192
+ anc_root = _etree.parse(parent_abs).getroot()
193
+ if anc_root.get("viewBox"):
194
+ view_box = anc_root.get("viewBox", view_box)
195
+ width = anc_root.get("width", width)
196
+ height = anc_root.get("height", height)
197
+
198
+ svg_content = (
199
+ f'<svg xmlns="{ns.SVG}"\n'
200
+ f' xmlns:inkflow="{ns.INKFLOW}"\n'
201
+ f' inkflow:parent="{parent}"\n'
202
+ f' viewBox="{view_box}" width="{width}" height="{height}">\n'
203
+ f"</svg>\n"
204
+ )
205
+ output_path.write_text(svg_content, encoding="utf-8")
206
+
207
+ chain = resolve_chain(output_path, project_dir, deck_obj.theme)
208
+ if chain:
209
+ inject_layout_layers(output_path, chain)
210
+
211
+ output_rel = output_path.relative_to(project_dir)
212
+ click.echo(f"[inkflow] created {output_rel}")
213
+ click.echo("[inkflow] add to deck.py:")
214
+ click.echo(f' Slide("{output_rel}"),')
215
+
216
+
217
+ @main.command("build")
218
+ @click.argument("deck", default="deck.py")
219
+ @click.option(
220
+ "--output",
221
+ "-o",
222
+ default=None,
223
+ help="Output directory (default: build/ next to deck.py)",
224
+ )
225
+ def build_cmd(deck: str, output: str | None) -> None:
226
+ """Export a self-contained presentation directory for offline use."""
227
+ deck_path = Path(deck).resolve()
228
+ if not deck_path.exists():
229
+ raise click.ClickException(f"deck not found: {deck_path}")
230
+ out_dir = Path(output).resolve() if output else deck_path.parent / "build"
231
+ build_static_html(deck_path, out_dir)
232
+ click.echo(f"[inkflow] built {out_dir / 'index.html'}")
233
+
234
+
235
+ @main.command("export")
236
+ @click.argument("deck", default="deck.py")
237
+ @click.option(
238
+ "--output",
239
+ "-o",
240
+ default=None,
241
+ help="Output PDF path (default: <deck-stem>.pdf next to deck.py)",
242
+ )
243
+ @click.option(
244
+ "--chromium",
245
+ default=None,
246
+ help="Path to chromium/chrome binary (auto-detected if not set)",
247
+ )
248
+ @click.option(
249
+ "--no-sandbox",
250
+ "no_sandbox",
251
+ is_flag=True,
252
+ help="Pass --no-sandbox to Chromium (needed when running as root or in Docker).",
253
+ )
254
+ def export_cmd(
255
+ deck: str, output: str | None, chromium: str | None, no_sandbox: bool
256
+ ) -> None:
257
+ """Export a PDF via headless Chromium (one page per slide)."""
258
+ deck_path = Path(deck).resolve()
259
+ if not deck_path.exists():
260
+ raise click.ClickException(f"deck not found: {deck_path}")
261
+ out = Path(output).resolve() if output else deck_path.with_suffix(".pdf")
262
+ try:
263
+ build_pdf(deck_path, out, chromium, no_sandbox)
264
+ except RuntimeError as exc:
265
+ raise click.ClickException(str(exc)) from exc
266
+ click.echo(f"[inkflow] exported {out}")
inkflow/content.py ADDED
@@ -0,0 +1,227 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import mimetypes
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ from lxml import etree
9
+
10
+ from inkflow import ns
11
+ from inkflow.manifest import Content, TextBox
12
+
13
+ _MIME_MAP = {
14
+ ".png": "image/png",
15
+ ".jpg": "image/jpeg",
16
+ ".jpeg": "image/jpeg",
17
+ ".gif": "image/gif",
18
+ ".webp": "image/webp",
19
+ ".svg": "image/svg+xml",
20
+ }
21
+
22
+ _VIDEO_SUFFIXES = {".mp4", ".webm", ".ogg", ".mov"}
23
+
24
+ _ALIGN_MAP: dict[str, tuple[int, int]] = {
25
+ "center": (50, 50),
26
+ "top": (50, 0),
27
+ "bottom": (50, 100),
28
+ "left": (0, 50),
29
+ "right": (100, 50),
30
+ "top-left": (0, 0),
31
+ "top-right": (100, 0),
32
+ "bottom-left": (0, 100),
33
+ "bottom-right": (100, 100),
34
+ }
35
+
36
+
37
+ @dataclass
38
+ class _ZoneRect:
39
+ x: str
40
+ y: str
41
+ width: str
42
+ height: str
43
+
44
+
45
+ def substitute_zone_numbers(svg_str: str, slide_number: int, total: int) -> str:
46
+ root = etree.fromstring(svg_str.encode())
47
+ for el in root.iter(f"{{{ns.SVG}}}text"):
48
+ eid = el.get("id", "")
49
+ if eid == "zone-slide-number":
50
+ el.text = str(slide_number)
51
+ elif eid == "zone-slide-total":
52
+ el.text = str(total)
53
+ return etree.tostring(root, encoding="unicode")
54
+
55
+
56
+ def _rect_geometry(el: etree._Element) -> _ZoneRect: # pyright: ignore[reportPrivateUsage]
57
+ x = el.get("x", "0")
58
+ y = el.get("y", "0")
59
+ w = el.get("width")
60
+ h = el.get("height")
61
+ if w is None or h is None:
62
+ raise ValueError(f"Zone rect missing width/height: {el.get('id')}")
63
+ return _ZoneRect(x=x, y=y, width=w, height=h)
64
+
65
+
66
+ def _swap_zone(
67
+ old_el: etree._Element, # pyright: ignore[reportPrivateUsage]
68
+ new_el: etree._Element, # pyright: ignore[reportPrivateUsage]
69
+ rect: _ZoneRect,
70
+ zone_id: str,
71
+ ) -> None:
72
+ """Set geometry + id on new_el and swap it in place of old_el in the tree."""
73
+ new_el.set("id", zone_id)
74
+ new_el.set("x", rect.x)
75
+ new_el.set("y", rect.y)
76
+ new_el.set("width", rect.width)
77
+ new_el.set("height", rect.height)
78
+ parent = old_el.getparent()
79
+ if parent is None:
80
+ return
81
+ idx = list(parent).index(old_el)
82
+ parent.remove(old_el)
83
+ parent.insert(idx, new_el)
84
+
85
+
86
+ def _replace_with_foreignobject(
87
+ el: etree._Element, # pyright: ignore[reportPrivateUsage]
88
+ html: str,
89
+ zone_id: str,
90
+ font_size: int,
91
+ ) -> None:
92
+ rect = _rect_geometry(el)
93
+
94
+ fo = etree.Element(f"{{{ns.SVG}}}foreignObject")
95
+ fo.set("overflow", "visible")
96
+ fo.set(
97
+ "font-size", str(font_size)
98
+ ) # SVG user units; cascades into HTML content via em
99
+
100
+ # Use XHTML as default namespace so lxml serialises <div>, <p>, <ul>
101
+ # without a prefix — required for the browser's HTML parser to recognise
102
+ # them as real HTML elements inside foreignObject.
103
+ div = etree.Element(
104
+ f"{{{ns.XHTML}}}div",
105
+ {"class": "inkflow-content"},
106
+ nsmap={None: ns.XHTML}, # pyright: ignore[reportArgumentType]
107
+ )
108
+ try:
109
+ fragment = etree.fromstring(f"<div xmlns='{ns.XHTML}'>{html}</div>")
110
+ div.text = fragment.text
111
+ for child in fragment:
112
+ div.append(child)
113
+ except etree.XMLSyntaxError:
114
+ div.text = html
115
+ fo.append(div)
116
+
117
+ _swap_zone(el, fo, rect, zone_id)
118
+
119
+
120
+ def _fmt_pos(base: int, offset_pct: float) -> str:
121
+ if offset_pct == 0.0:
122
+ return f"{base}%"
123
+ sign = "+" if offset_pct >= 0 else "-"
124
+ return f"calc({base}% {sign} {abs(offset_pct):.6g}%)"
125
+
126
+
127
+ def _replace_with_media(
128
+ el: etree._Element, # pyright: ignore[reportPrivateUsage]
129
+ src: str,
130
+ zone_id: str,
131
+ fit: str,
132
+ align: str,
133
+ x: float,
134
+ y: float,
135
+ project_dir: Path,
136
+ ) -> None:
137
+ rect = _rect_geometry(el)
138
+
139
+ base_x, base_y = _ALIGN_MAP.get(align, (50, 50))
140
+ x_pct = x / float(rect.width) * 100
141
+ y_pct = y / float(rect.height) * 100
142
+ style = (
143
+ f"width:100%;height:100%;"
144
+ f"object-fit:{fit};"
145
+ f"object-position:{_fmt_pos(base_x, x_pct)} {_fmt_pos(base_y, y_pct)};"
146
+ f"display:block;"
147
+ )
148
+
149
+ fo = etree.Element(f"{{{ns.SVG}}}foreignObject")
150
+ fo.set("overflow", "visible")
151
+
152
+ suffix = Path(src).suffix.lower()
153
+ if suffix in _VIDEO_SUFFIXES:
154
+ media_el = etree.Element(
155
+ f"{{{ns.XHTML}}}video",
156
+ {"src": src, "controls": ""},
157
+ nsmap={None: ns.XHTML}, # pyright: ignore[reportArgumentType]
158
+ )
159
+ else:
160
+ img_path = project_dir / src
161
+ mime = (
162
+ _MIME_MAP.get(suffix)
163
+ or mimetypes.guess_type(str(img_path))[0]
164
+ or "image/png"
165
+ )
166
+ b64 = base64.b64encode(img_path.read_bytes()).decode()
167
+ media_el = etree.Element(
168
+ f"{{{ns.XHTML}}}img",
169
+ {"src": f"data:{mime};base64,{b64}"},
170
+ nsmap={None: ns.XHTML}, # pyright: ignore[reportArgumentType]
171
+ )
172
+
173
+ media_el.set("style", style)
174
+ fo.append(media_el)
175
+ _swap_zone(el, fo, rect, zone_id)
176
+
177
+
178
+ def substitute_content(
179
+ svg_str: str,
180
+ content: list[Content],
181
+ project_dir: Path,
182
+ font_size: int = 36,
183
+ ) -> str:
184
+ root = etree.fromstring(svg_str.encode())
185
+
186
+ for item in content:
187
+ zone_id = item.element.lstrip("#")
188
+ el = root.find(f'.//*[@id="{zone_id}"]')
189
+ if el is None:
190
+ print(f"[inkflow] warning: zone #{zone_id} not found in SVG")
191
+ continue
192
+
193
+ if isinstance(item, TextBox):
194
+ _replace_with_foreignobject(el, item.text or "", zone_id, font_size)
195
+ else:
196
+ _replace_with_media(
197
+ el, item.src, zone_id, item.fit, item.align, item.x, item.y, project_dir
198
+ )
199
+
200
+ return etree.tostring(root, encoding="unicode")
201
+
202
+
203
+ def remove_unreferenced_zones(svg_str: str) -> str:
204
+ root = etree.fromstring(svg_str.encode())
205
+ to_remove = [
206
+ el
207
+ for el in root.iter(f"{{{ns.SVG}}}rect")
208
+ if (el.get("id") or "").startswith("zone-") and not el.get("class")
209
+ ]
210
+ for el in to_remove:
211
+ parent = el.getparent()
212
+ if parent is not None:
213
+ parent.remove(el)
214
+ return etree.tostring(root, encoding="unicode")
215
+
216
+
217
+ def inject_style(svg_str: str, css: str) -> str:
218
+ if not css:
219
+ return svg_str
220
+ root = etree.fromstring(svg_str.encode())
221
+ defs = root.find(f"{{{ns.SVG}}}defs")
222
+ if defs is None:
223
+ defs = etree.Element(f"{{{ns.SVG}}}defs")
224
+ root.insert(0, defs)
225
+ style = etree.SubElement(defs, f"{{{ns.SVG}}}style")
226
+ style.text = css
227
+ return etree.tostring(root, encoding="unicode")
inkflow/export.py ADDED
@@ -0,0 +1,123 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib.resources
4
+ import shutil
5
+ import subprocess
6
+ import tempfile
7
+ from pathlib import Path
8
+
9
+ from inkflow.manifest import Deck, MarkdownSlide, Media, Slide
10
+ from inkflow.pipeline import process_deck, resolve_transitions
11
+ from inkflow.server import State, build_html, load_deck, load_styles
12
+
13
+ _VIDEO_SUFFIXES = {".mp4", ".webm", ".ogg", ".mov"}
14
+
15
+
16
+ # ── build ─────────────────────────────────────────────────────────────────────
17
+
18
+
19
+ def build_static_html(deck_path: Path, out_dir: Path) -> None:
20
+ deck = load_deck(deck_path)
21
+ project_dir = deck_path.parent
22
+ slides = process_deck(deck, project_dir)
23
+ transitions = resolve_transitions(deck)
24
+ styles_css = load_styles(deck, project_dir)
25
+
26
+ _copy_videos(_collect_video_paths(deck), project_dir, out_dir)
27
+
28
+ state: State = {
29
+ "slides": slides,
30
+ "transitions": transitions,
31
+ "styles_css": styles_css,
32
+ "dark_mode": deck.dark_mode,
33
+ "ws_clients": set(),
34
+ "error": None,
35
+ }
36
+ out_dir.mkdir(parents=True, exist_ok=True)
37
+ (out_dir / "index.html").write_bytes(build_html(state, ws_port=None))
38
+
39
+
40
+ def _collect_video_paths(deck: Deck) -> list[str]:
41
+ paths: list[str] = []
42
+ for slide in deck.slides:
43
+ items = slide.content if isinstance(slide, Slide) else []
44
+ for item in items:
45
+ if (
46
+ isinstance(item, Media)
47
+ and Path(item.src).suffix.lower() in _VIDEO_SUFFIXES
48
+ ):
49
+ paths.append(item.src)
50
+ if isinstance(slide, MarkdownSlide):
51
+ for val in slide._extra.values(): # pyright: ignore[reportPrivateUsage]
52
+ src = val.src if isinstance(val, Media) else val
53
+ if Path(src).suffix.lower() in _VIDEO_SUFFIXES:
54
+ paths.append(src)
55
+ return paths
56
+
57
+
58
+ def _copy_videos(paths: list[str], project_dir: Path, out_dir: Path) -> None:
59
+ for rel in paths:
60
+ src = project_dir / rel
61
+ dst = out_dir / rel
62
+ if src.exists():
63
+ dst.parent.mkdir(parents=True, exist_ok=True)
64
+ shutil.copy2(src, dst)
65
+
66
+
67
+ # ── export (PDF) ──────────────────────────────────────────────────────────────
68
+
69
+
70
+ def build_pdf(
71
+ deck_path: Path,
72
+ output: Path,
73
+ chromium: str | None = None,
74
+ no_sandbox: bool = False,
75
+ ) -> None:
76
+ exe = chromium or _find_chromium()
77
+ if exe is None:
78
+ raise RuntimeError(
79
+ "Chromium not found. Install chromium or google-chrome,"
80
+ + " or pass --chromium PATH."
81
+ )
82
+
83
+ deck = load_deck(deck_path)
84
+ project_dir = deck_path.parent
85
+ slides = process_deck(deck, project_dir)
86
+ styles_css = load_styles(deck, project_dir)
87
+
88
+ pkg = importlib.resources.files("inkflow")
89
+ template = pkg.joinpath("pdf.html").read_text(encoding="utf-8")
90
+ data_theme = "" if deck.dark_mode else "light"
91
+ slides_html = "\n".join(f'<div class="slide">{s}</div>' for s in slides)
92
+ html = (
93
+ template.replace("__STYLES__", styles_css)
94
+ .replace("__DATA_THEME__", data_theme)
95
+ .replace("__SLIDES__", slides_html)
96
+ )
97
+
98
+ with tempfile.TemporaryDirectory() as tmp:
99
+ html_path = Path(tmp) / "slides.html"
100
+ html_path.write_text(html, encoding="utf-8")
101
+ cmd = [
102
+ exe,
103
+ "--headless",
104
+ "--disable-gpu",
105
+ "--print-to-pdf-no-header",
106
+ f"--print-to-pdf={output.resolve()}",
107
+ html_path.as_uri(),
108
+ ]
109
+ if no_sandbox:
110
+ cmd.insert(1, "--no-sandbox")
111
+ subprocess.run(cmd, check=True)
112
+
113
+
114
+ def _find_chromium() -> str | None:
115
+ for name in (
116
+ "chromium",
117
+ "chromium-browser",
118
+ "google-chrome",
119
+ "google-chrome-stable",
120
+ ):
121
+ if found := shutil.which(name):
122
+ return found
123
+ return None