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/__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
|