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