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/layout.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import importlib.resources
|
|
5
|
+
from copy import deepcopy
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import cast
|
|
8
|
+
|
|
9
|
+
from lxml import etree
|
|
10
|
+
|
|
11
|
+
from inkflow import ns
|
|
12
|
+
from inkflow.ns import (
|
|
13
|
+
INKFLOW_LAYOUT_HASH,
|
|
14
|
+
INKFLOW_LAYOUT_SRC,
|
|
15
|
+
INKFLOW_PARENT,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
# ── Built-in theme ───────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _builtin_theme_dir() -> Path:
|
|
22
|
+
return Path(str(importlib.resources.files("inkflow").joinpath("theme")))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def resolve_theme_dir(theme: str, project_root: Path) -> Path:
|
|
26
|
+
p = Path(theme)
|
|
27
|
+
if p.is_absolute():
|
|
28
|
+
return p
|
|
29
|
+
if theme.startswith(("./", "../")):
|
|
30
|
+
return (project_root / p).resolve()
|
|
31
|
+
raise ValueError(
|
|
32
|
+
f"Named theme '{theme}' is not yet supported. Use a path like './my-theme'."
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ── Parent attribute ──────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _read_parent_attr(svg_path: Path) -> str | None:
|
|
40
|
+
tree = etree.parse(svg_path)
|
|
41
|
+
return tree.getroot().get(INKFLOW_PARENT)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ── Path resolution ───────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def resolve_parent_path(
|
|
48
|
+
parent_str: str,
|
|
49
|
+
svg_path: Path,
|
|
50
|
+
project_root: Path,
|
|
51
|
+
theme: str | None,
|
|
52
|
+
) -> Path:
|
|
53
|
+
"""Resolve an inkflow:parent string to an absolute Path.
|
|
54
|
+
|
|
55
|
+
Prefix syntaxes (bypass the search):
|
|
56
|
+
local:foo → {project_root}/layouts/foo.svg
|
|
57
|
+
theme:foo → {theme_dir}/layouts/foo.svg
|
|
58
|
+
builtin:foo → {builtin_theme_dir}/layouts/foo.svg
|
|
59
|
+
./foo, ../foo → relative to svg_path's directory
|
|
60
|
+
/absolute → literal filesystem path
|
|
61
|
+
|
|
62
|
+
Bare single-part name (no prefix, no separator):
|
|
63
|
+
Three-level search: project layouts/ → theme layouts/ → built-in layouts/
|
|
64
|
+
Multi-part relative path (has /, no prefix):
|
|
65
|
+
Relative to svg_path's directory (backward-compatible with inkflow:parent).
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def _with_svg(p: Path) -> Path:
|
|
69
|
+
return p if p.suffix else p.with_suffix(".svg")
|
|
70
|
+
|
|
71
|
+
if parent_str.startswith("local:"):
|
|
72
|
+
name = parent_str[len("local:") :]
|
|
73
|
+
resolved = _with_svg(project_root / "layouts" / name)
|
|
74
|
+
if not resolved.exists():
|
|
75
|
+
raise ValueError(f"local:{name} not found at {resolved}")
|
|
76
|
+
return resolved
|
|
77
|
+
|
|
78
|
+
if parent_str.startswith("theme:"):
|
|
79
|
+
name = parent_str[len("theme:") :]
|
|
80
|
+
if theme is None:
|
|
81
|
+
raise ValueError(f"theme:{name} requires Deck(theme=...) to be set")
|
|
82
|
+
theme_dir = resolve_theme_dir(theme, project_root)
|
|
83
|
+
resolved = _with_svg(theme_dir / "layouts" / name)
|
|
84
|
+
if not resolved.exists():
|
|
85
|
+
raise ValueError(f"theme:{name} not found at {resolved}")
|
|
86
|
+
return resolved
|
|
87
|
+
|
|
88
|
+
if parent_str.startswith("builtin:"):
|
|
89
|
+
name = parent_str[len("builtin:") :]
|
|
90
|
+
resolved = _with_svg(_builtin_theme_dir() / "layouts" / name)
|
|
91
|
+
if not resolved.exists():
|
|
92
|
+
raise ValueError(
|
|
93
|
+
f"builtin:{name} not found — no built-in layout named '{name}'"
|
|
94
|
+
)
|
|
95
|
+
return resolved
|
|
96
|
+
|
|
97
|
+
if parent_str.startswith(("./", "../")):
|
|
98
|
+
return _with_svg((svg_path.parent / parent_str).resolve())
|
|
99
|
+
|
|
100
|
+
if parent_str.startswith("/"):
|
|
101
|
+
return _with_svg(Path(parent_str))
|
|
102
|
+
|
|
103
|
+
# Multi-part relative path (has /) — relative to svg_path's parent
|
|
104
|
+
if "/" in parent_str:
|
|
105
|
+
return _with_svg((svg_path.parent / parent_str).resolve())
|
|
106
|
+
|
|
107
|
+
# Bare single-part name — three-level search
|
|
108
|
+
name = parent_str
|
|
109
|
+
candidates: list[Path] = [_with_svg(project_root / "layouts" / name)]
|
|
110
|
+
if theme is not None:
|
|
111
|
+
candidates.append(
|
|
112
|
+
_with_svg(resolve_theme_dir(theme, project_root) / "layouts" / name)
|
|
113
|
+
)
|
|
114
|
+
candidates.append(_with_svg(_builtin_theme_dir() / "layouts" / name))
|
|
115
|
+
|
|
116
|
+
for candidate in candidates:
|
|
117
|
+
if candidate.exists():
|
|
118
|
+
return candidate
|
|
119
|
+
|
|
120
|
+
searched = "\n".join(f" {c}" for c in candidates)
|
|
121
|
+
raise ValueError(f"Layout '{name}' not found. Searched:\n{searched}")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ── Chain resolution ──────────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def resolve_chain(
|
|
128
|
+
svg_path: Path,
|
|
129
|
+
project_root: Path,
|
|
130
|
+
theme: str | None,
|
|
131
|
+
) -> list[Path]:
|
|
132
|
+
"""Return the ancestor chain for svg_path, root-first, excluding svg_path itself.
|
|
133
|
+
|
|
134
|
+
Returns an empty list if the file has no inkflow:parent.
|
|
135
|
+
Raises ValueError on circular chains.
|
|
136
|
+
"""
|
|
137
|
+
chain: list[Path] = []
|
|
138
|
+
current = svg_path.resolve()
|
|
139
|
+
visited: set[Path] = {current}
|
|
140
|
+
|
|
141
|
+
while True:
|
|
142
|
+
parent_str = _read_parent_attr(current)
|
|
143
|
+
if parent_str is None:
|
|
144
|
+
break
|
|
145
|
+
parent_path = resolve_parent_path(parent_str, current, project_root, theme)
|
|
146
|
+
if parent_path in visited:
|
|
147
|
+
raise ValueError(f"Circular inkflow:parent chain detected at {parent_path}")
|
|
148
|
+
visited.add(parent_path)
|
|
149
|
+
chain.insert(0, parent_path)
|
|
150
|
+
current = parent_path
|
|
151
|
+
|
|
152
|
+
return chain
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# ── Layout layer stripping ────────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def strip_layout_layers(root: etree._Element) -> None: # pyright: ignore[reportPrivateUsage]
|
|
159
|
+
"""Remove direct-child <g> elements injected by inject_layout_layers."""
|
|
160
|
+
to_remove = [el for el in root if el.get(INKFLOW_LAYOUT_SRC) is not None]
|
|
161
|
+
for el in to_remove:
|
|
162
|
+
root.remove(el)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# ── inject_layout_layers ──────────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _layer_hashes(chain: list[Path]) -> dict[str, str]:
|
|
169
|
+
hashes: dict[str, str] = {}
|
|
170
|
+
for p in chain:
|
|
171
|
+
digest = hashlib.sha1(p.read_bytes()).hexdigest()[:8]
|
|
172
|
+
hashes[str(p.resolve())] = digest
|
|
173
|
+
return hashes
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _chain_refs(svg_path: Path, chain: list[Path]) -> list[str]:
|
|
177
|
+
"""Return the inkflow:parent ref string for each ancestor in the chain.
|
|
178
|
+
|
|
179
|
+
The ref for chain[i] is the inkflow:parent value on its child — chain[i+1]
|
|
180
|
+
for all but the last entry, svg_path for the last.
|
|
181
|
+
"""
|
|
182
|
+
children = [*chain[1:], svg_path]
|
|
183
|
+
return [_read_parent_attr(child) or "" for child in children]
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def is_layout_current(svg_path: Path, chain: list[Path]) -> bool:
|
|
187
|
+
"""Return True if svg_path already has up-to-date inject-layout layers."""
|
|
188
|
+
root = etree.parse(svg_path).getroot()
|
|
189
|
+
existing = [el for el in root if el.get(INKFLOW_LAYOUT_SRC) is not None]
|
|
190
|
+
if len(existing) != len(chain):
|
|
191
|
+
return False
|
|
192
|
+
new_hashes = _layer_hashes(chain)
|
|
193
|
+
refs = _chain_refs(svg_path, chain)
|
|
194
|
+
for el, p, ref in zip(existing, chain, refs, strict=True):
|
|
195
|
+
if el.get(INKFLOW_LAYOUT_SRC) != ref:
|
|
196
|
+
return False
|
|
197
|
+
if el.get(INKFLOW_LAYOUT_HASH) != new_hashes[str(p.resolve())]:
|
|
198
|
+
return False
|
|
199
|
+
return True
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _build_layer_group(
|
|
203
|
+
ancestor_path: Path, ref: str, hashes: dict[str, str]
|
|
204
|
+
) -> etree._Element: # pyright: ignore[reportPrivateUsage]
|
|
205
|
+
anc_root = etree.parse(ancestor_path).getroot()
|
|
206
|
+
strip_layout_layers(anc_root)
|
|
207
|
+
|
|
208
|
+
g = etree.Element(
|
|
209
|
+
f"{{{ns.SVG}}}g",
|
|
210
|
+
{
|
|
211
|
+
f"{{{ns.INKSCAPE}}}groupmode": "layer",
|
|
212
|
+
f"{{{ns.INKSCAPE}}}label": f"__inkflow:layout:{ancestor_path.stem}__",
|
|
213
|
+
f"{{{ns.SODIPODI}}}insensitive": "true",
|
|
214
|
+
INKFLOW_LAYOUT_SRC: ref,
|
|
215
|
+
INKFLOW_LAYOUT_HASH: hashes[str(ancestor_path.resolve())],
|
|
216
|
+
},
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
defs_children: list[etree._Element] = [] # pyright: ignore[reportPrivateUsage]
|
|
220
|
+
for defs_el in anc_root.findall(f"{{{ns.SVG}}}defs"):
|
|
221
|
+
defs_children.extend(list(defs_el))
|
|
222
|
+
if defs_children:
|
|
223
|
+
g_defs = etree.SubElement(g, f"{{{ns.SVG}}}defs")
|
|
224
|
+
for def_el in defs_children:
|
|
225
|
+
g_defs.append(deepcopy(def_el))
|
|
226
|
+
|
|
227
|
+
for child in anc_root:
|
|
228
|
+
if child.tag != f"{{{ns.SVG}}}defs":
|
|
229
|
+
g.append(deepcopy(child))
|
|
230
|
+
|
|
231
|
+
return g
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _with_namespaces(
|
|
235
|
+
root: etree._Element, # pyright: ignore[reportPrivateUsage]
|
|
236
|
+
additions: dict[str, str],
|
|
237
|
+
) -> etree._Element: # pyright: ignore[reportPrivateUsage]
|
|
238
|
+
"""Return root with extra namespace prefixes declared.
|
|
239
|
+
|
|
240
|
+
lxml nsmap is immutable after construction, so adding prefixes requires
|
|
241
|
+
rebuilding the root element with an extended nsmap.
|
|
242
|
+
"""
|
|
243
|
+
missing = {k: v for k, v in additions.items() if k not in root.nsmap}
|
|
244
|
+
if not missing:
|
|
245
|
+
return root
|
|
246
|
+
new_root = etree.Element(
|
|
247
|
+
root.tag,
|
|
248
|
+
attrib=cast("dict[str, str]", dict(root.attrib)),
|
|
249
|
+
nsmap=cast("dict[str, str]", {**root.nsmap, **missing}),
|
|
250
|
+
)
|
|
251
|
+
for child in root:
|
|
252
|
+
new_root.append(child)
|
|
253
|
+
return new_root
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def inject_layout_layers(svg_path: Path, chain: list[Path]) -> bool:
|
|
257
|
+
"""Inject ancestor SVGs as locked Inkscape layers into svg_path in place.
|
|
258
|
+
|
|
259
|
+
Returns True if the file was modified, False if already up to date.
|
|
260
|
+
"""
|
|
261
|
+
if is_layout_current(svg_path, chain):
|
|
262
|
+
return False
|
|
263
|
+
|
|
264
|
+
root = etree.parse(svg_path).getroot()
|
|
265
|
+
hashes = _layer_hashes(chain)
|
|
266
|
+
|
|
267
|
+
for el in [el for el in root if el.get(INKFLOW_LAYOUT_SRC) is not None]:
|
|
268
|
+
root.remove(el)
|
|
269
|
+
|
|
270
|
+
refs = _chain_refs(svg_path, chain)
|
|
271
|
+
for i, (ancestor_path, ref) in enumerate(zip(chain, refs, strict=True)):
|
|
272
|
+
root.insert(i, _build_layer_group(ancestor_path, ref, hashes))
|
|
273
|
+
|
|
274
|
+
out = _with_namespaces(root, {"inkscape": ns.INKSCAPE, "sodipodi": ns.SODIPODI})
|
|
275
|
+
svg_path.write_text(
|
|
276
|
+
etree.tostring(out, encoding="unicode", xml_declaration=False),
|
|
277
|
+
encoding="utf-8",
|
|
278
|
+
)
|
|
279
|
+
return True
|
inkflow/manifest.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Protocol, runtime_checkable
|
|
5
|
+
|
|
6
|
+
# ── Animation ────────────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class FadeIn:
|
|
11
|
+
element: str
|
|
12
|
+
step: int = 1
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class FadeOut:
|
|
17
|
+
element: str
|
|
18
|
+
step: int = 1
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Bounce:
|
|
23
|
+
element: str
|
|
24
|
+
step: int = 1
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@runtime_checkable
|
|
28
|
+
class Animation(Protocol):
|
|
29
|
+
element: str
|
|
30
|
+
step: int
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ── Transition ────────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class Cut:
|
|
38
|
+
duration: float = 0.0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class Crossfade:
|
|
43
|
+
duration: float = 0.4
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class Morph:
|
|
48
|
+
duration: float = 0.5
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@runtime_checkable
|
|
52
|
+
class Transition(Protocol):
|
|
53
|
+
duration: float
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ── Content types ─────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class TextBox:
|
|
61
|
+
element: str
|
|
62
|
+
text: str | None = None
|
|
63
|
+
steps: bool = False
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class Media:
|
|
68
|
+
src: str
|
|
69
|
+
fit: str = "contain"
|
|
70
|
+
align: str = "center"
|
|
71
|
+
x: float = 0.0
|
|
72
|
+
y: float = 0.0
|
|
73
|
+
element: str = field(default="", kw_only=True)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
Content = TextBox | Media
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ── Slide / Deck ──────────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class Slide:
|
|
84
|
+
src: str
|
|
85
|
+
animations: list[Animation] = field(default_factory=list)
|
|
86
|
+
transition: Transition | None = None
|
|
87
|
+
content: list[Content] = field(default_factory=list)
|
|
88
|
+
style: str = ""
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def step_count(self) -> int:
|
|
92
|
+
return max((a.step for a in self.animations), default=0)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class MarkdownSlide:
|
|
96
|
+
template: str
|
|
97
|
+
content: str | None
|
|
98
|
+
steps: bool
|
|
99
|
+
animations: list[Animation]
|
|
100
|
+
transition: Transition | None
|
|
101
|
+
style: str
|
|
102
|
+
_extra: dict[str, str | Media]
|
|
103
|
+
|
|
104
|
+
def __init__(
|
|
105
|
+
self,
|
|
106
|
+
template: str,
|
|
107
|
+
*,
|
|
108
|
+
content: str | None = None,
|
|
109
|
+
steps: bool = False,
|
|
110
|
+
animations: list[Animation] | None = None,
|
|
111
|
+
transition: Transition | None = None,
|
|
112
|
+
style: str = "",
|
|
113
|
+
**kwargs: str | Media,
|
|
114
|
+
) -> None:
|
|
115
|
+
self.template = template
|
|
116
|
+
self.content = content
|
|
117
|
+
self.steps = steps
|
|
118
|
+
self.animations = animations or []
|
|
119
|
+
self.transition = transition
|
|
120
|
+
self.style = style
|
|
121
|
+
self._extra = kwargs
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class Deck:
|
|
125
|
+
slides: list[Slide | MarkdownSlide]
|
|
126
|
+
transition: Transition | None
|
|
127
|
+
theme: str | None
|
|
128
|
+
dark_mode: bool
|
|
129
|
+
style: str
|
|
130
|
+
font_size: int
|
|
131
|
+
|
|
132
|
+
def __init__(
|
|
133
|
+
self,
|
|
134
|
+
transition: Transition | None = None,
|
|
135
|
+
theme: str | None = None,
|
|
136
|
+
dark_mode: bool = True,
|
|
137
|
+
style: str = "",
|
|
138
|
+
font_size: int = 36,
|
|
139
|
+
) -> None:
|
|
140
|
+
self.slides = []
|
|
141
|
+
self.transition = transition
|
|
142
|
+
self.theme = theme
|
|
143
|
+
self.dark_mode = dark_mode
|
|
144
|
+
self.style = style
|
|
145
|
+
self.font_size = font_size
|
inkflow/markdown.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import replace
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import cast
|
|
7
|
+
|
|
8
|
+
from markdown_it import MarkdownIt
|
|
9
|
+
|
|
10
|
+
from inkflow.manifest import Content, Media, TextBox
|
|
11
|
+
|
|
12
|
+
_ZONE_PATTERN = re.compile(r"^::((?!step\b)[\w-]+)::\s*$", re.MULTILINE)
|
|
13
|
+
_STEP_PATTERN = re.compile(r"^::step::\s*$", re.MULTILINE)
|
|
14
|
+
_STEP = "\x00step\x00"
|
|
15
|
+
|
|
16
|
+
_md = MarkdownIt()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def markdown_to_html(md_str: str) -> str:
|
|
20
|
+
return cast(str, _md.render(md_str))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _split_steps(text: str) -> list[str]:
|
|
24
|
+
parts = _STEP_PATTERN.split(text)
|
|
25
|
+
result: list[str] = []
|
|
26
|
+
for i, part in enumerate(parts):
|
|
27
|
+
if i > 0:
|
|
28
|
+
result.append(_STEP)
|
|
29
|
+
result.append(part)
|
|
30
|
+
return result
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _auto_extract(text: str) -> dict[str, list[str]]:
|
|
34
|
+
zones: dict[str, list[str]] = {}
|
|
35
|
+
lines = text.splitlines(keepends=True)
|
|
36
|
+
i = 0
|
|
37
|
+
n = len(lines)
|
|
38
|
+
|
|
39
|
+
# Extract leading # H1
|
|
40
|
+
while i < n and not lines[i].strip():
|
|
41
|
+
i += 1
|
|
42
|
+
if i < n and lines[i].startswith("# "):
|
|
43
|
+
zones["title"] = [lines[i].rstrip()]
|
|
44
|
+
i += 1
|
|
45
|
+
|
|
46
|
+
# Check for ## H2 immediately following (only blank lines between)
|
|
47
|
+
j = i
|
|
48
|
+
while j < n and not lines[j].strip():
|
|
49
|
+
j += 1
|
|
50
|
+
if j < n and lines[j].startswith("## "):
|
|
51
|
+
zones["subtitle"] = [lines[j].rstrip()]
|
|
52
|
+
i = j + 1
|
|
53
|
+
|
|
54
|
+
rest = "".join(lines[i:]).strip()
|
|
55
|
+
if rest:
|
|
56
|
+
zones["content"] = _split_steps(rest)
|
|
57
|
+
|
|
58
|
+
return zones
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def parse_markdown_zones(md_path: Path) -> dict[str, list[str]]:
|
|
62
|
+
text = md_path.read_text(encoding="utf-8")
|
|
63
|
+
|
|
64
|
+
markers = list(_ZONE_PATTERN.finditer(text))
|
|
65
|
+
if not markers:
|
|
66
|
+
return _auto_extract(text)
|
|
67
|
+
|
|
68
|
+
zones: dict[str, list[str]] = {}
|
|
69
|
+
|
|
70
|
+
# Content before the first marker:
|
|
71
|
+
# auto-extract title/subtitle, remainder → "content"
|
|
72
|
+
before = text[: markers[0].start()].strip()
|
|
73
|
+
if before:
|
|
74
|
+
zones.update(_auto_extract(before))
|
|
75
|
+
|
|
76
|
+
for idx, m in enumerate(markers):
|
|
77
|
+
zone_name = m.group(1)
|
|
78
|
+
start = m.end()
|
|
79
|
+
end = markers[idx + 1].start() if idx + 1 < len(markers) else len(text)
|
|
80
|
+
section = text[start:end].strip()
|
|
81
|
+
if section:
|
|
82
|
+
zones[zone_name] = _split_steps(section)
|
|
83
|
+
|
|
84
|
+
return zones
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def chunks_to_html(chunks: list[str], base_step: int) -> tuple[str, int]:
|
|
88
|
+
parts: list[str] = []
|
|
89
|
+
step = base_step
|
|
90
|
+
chunk_index = 0
|
|
91
|
+
|
|
92
|
+
for item in chunks:
|
|
93
|
+
if item == _STEP:
|
|
94
|
+
step += 1
|
|
95
|
+
continue
|
|
96
|
+
if chunk_index == 0:
|
|
97
|
+
parts.append(markdown_to_html(item))
|
|
98
|
+
else:
|
|
99
|
+
html = markdown_to_html(item)
|
|
100
|
+
parts.append(f'<div class="anim-fade-in" data-step="{step}">{html}</div>')
|
|
101
|
+
chunk_index += 1
|
|
102
|
+
|
|
103
|
+
return "".join(parts), step
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def steps_wrap_list_items(html: str, base_step: int) -> tuple[str, int]:
|
|
107
|
+
from lxml import etree
|
|
108
|
+
|
|
109
|
+
# Wrap in a root element to parse as fragment
|
|
110
|
+
wrapped = f"<div>{html}</div>"
|
|
111
|
+
root = etree.fromstring(wrapped.encode())
|
|
112
|
+
|
|
113
|
+
step = base_step
|
|
114
|
+
for ul_or_ol in root.findall("ul") + root.findall("ol"):
|
|
115
|
+
for li in ul_or_ol:
|
|
116
|
+
if li.tag != "li":
|
|
117
|
+
continue
|
|
118
|
+
step += 1
|
|
119
|
+
wrapper = etree.Element("div")
|
|
120
|
+
wrapper.set("class", "anim-fade-in")
|
|
121
|
+
wrapper.set("data-step", str(step))
|
|
122
|
+
# Move li into wrapper
|
|
123
|
+
idx = list(ul_or_ol).index(li)
|
|
124
|
+
ul_or_ol.remove(li)
|
|
125
|
+
wrapper.append(li)
|
|
126
|
+
ul_or_ol.insert(idx, wrapper)
|
|
127
|
+
|
|
128
|
+
inner = etree.tostring(root, encoding="unicode")
|
|
129
|
+
# Strip outer <div> wrapper
|
|
130
|
+
inner = inner[len("<div>") : -len("</div>")]
|
|
131
|
+
return inner, step
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def build_slide_content(
|
|
135
|
+
content_path: Path | None,
|
|
136
|
+
steps: bool,
|
|
137
|
+
extra: dict[str, str | Media],
|
|
138
|
+
) -> list[Content]:
|
|
139
|
+
zones: dict[str, list[str]] = {}
|
|
140
|
+
if content_path is not None:
|
|
141
|
+
zones = parse_markdown_zones(content_path)
|
|
142
|
+
|
|
143
|
+
content: list[Content] = []
|
|
144
|
+
base_step = 0
|
|
145
|
+
|
|
146
|
+
for zone_name, chunks in zones.items():
|
|
147
|
+
html, base_step = chunks_to_html(chunks, base_step)
|
|
148
|
+
if steps:
|
|
149
|
+
html, base_step = steps_wrap_list_items(html, base_step)
|
|
150
|
+
content.append(TextBox(f"#zone-{zone_name}", text=html))
|
|
151
|
+
|
|
152
|
+
for key, val in extra.items():
|
|
153
|
+
if isinstance(val, Media):
|
|
154
|
+
content.append(replace(val, element=f"#zone-{key}"))
|
|
155
|
+
else:
|
|
156
|
+
content.append(Media(val, element=f"#zone-{key}"))
|
|
157
|
+
|
|
158
|
+
return content
|
inkflow/ns.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
SVG = "http://www.w3.org/2000/svg"
|
|
2
|
+
XHTML = "http://www.w3.org/1999/xhtml"
|
|
3
|
+
INKFLOW = "urn:inkflow"
|
|
4
|
+
INKSCAPE = "http://www.inkscape.org/namespaces/inkscape"
|
|
5
|
+
SODIPODI = "http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
|
6
|
+
|
|
7
|
+
INKFLOW_PARENT = f"{{{INKFLOW}}}parent"
|
|
8
|
+
INKFLOW_LAYOUT_SRC = f"{{{INKFLOW}}}layout-src"
|
|
9
|
+
INKFLOW_LAYOUT_HASH = f"{{{INKFLOW}}}layout-hash"
|
inkflow/pdf.html
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en" data-theme="__DATA_THEME__">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<style>
|
|
6
|
+
@page { size: 1920px 1080px; margin: 0; }
|
|
7
|
+
* { box-sizing: border-box; }
|
|
8
|
+
body { margin: 0; padding: 0; }
|
|
9
|
+
.slide { width: 1920px; height: 1080px; overflow: hidden; break-after: page; }
|
|
10
|
+
.slide svg { width: 100%; height: 100%; display: block; }
|
|
11
|
+
[data-step] { opacity: 1 !important; transform: none !important; }
|
|
12
|
+
.anim-fade-out[data-step] { opacity: 0 !important; }
|
|
13
|
+
</style>
|
|
14
|
+
<style>__STYLES__</style>
|
|
15
|
+
</head>
|
|
16
|
+
<body>
|
|
17
|
+
__SLIDES__
|
|
18
|
+
</body>
|
|
19
|
+
</html>
|