decktile 0.2.0__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 (46) hide show
  1. decktile/__init__.py +5 -0
  2. decktile/__main__.py +3 -0
  3. decktile/assets/lucide/VENDORED.md +21 -0
  4. decktile/assets/lucide/lucide.css +2193 -0
  5. decktile/assets/lucide/lucide.ttf +3 -0
  6. decktile/cli.py +269 -0
  7. decktile/parser/__init__.py +0 -0
  8. decktile/parser/ast.py +191 -0
  9. decktile/parser/markdown.py +289 -0
  10. decktile/plugins/cards.py +25 -0
  11. decktile/plugins/directives.py +56 -0
  12. decktile/plugins/fenced.py +228 -0
  13. decktile/plugins/layouts.py +69 -0
  14. decktile/plugins/lucide.py +65 -0
  15. decktile/plugins/lucide_font.py +26 -0
  16. decktile/render/__init__.py +0 -0
  17. decktile/render/html.py +287 -0
  18. decktile/render/ir.py +536 -0
  19. decktile/render/odf/__init__.py +4 -0
  20. decktile/render/odf/geometry.py +510 -0
  21. decktile/render/odf/shapes.py +719 -0
  22. decktile/render/odf/styles.py +828 -0
  23. decktile/render/odp.py +380 -0
  24. decktile/render/pdf.py +17 -0
  25. decktile/render/pdg.py +72 -0
  26. decktile/render/seaborn.py +155 -0
  27. decktile/render/templates/base.css +741 -0
  28. decktile/render/templates/decktile.js +371 -0
  29. decktile/render/templates/shell.html +106 -0
  30. decktile/seaborn_styles/__init__.py +5 -0
  31. decktile/seaborn_styles/ossummit_europe.py +30 -0
  32. decktile/theme/__init__.py +0 -0
  33. decktile/theme/colors.py +53 -0
  34. decktile/theme/loader.py +22 -0
  35. decktile/theme/parser.py +264 -0
  36. decktile/themes/default.css +146 -0
  37. decktile/themes/kcd_vietnam.css +159 -0
  38. decktile/themes/kubecon_china.css +213 -0
  39. decktile/themes/kubecon_japan.css +156 -0
  40. decktile/themes/ossummit_europe.css +196 -0
  41. decktile/themes/ossummit_korea.css +196 -0
  42. decktile-0.2.0.dist-info/METADATA +625 -0
  43. decktile-0.2.0.dist-info/RECORD +46 -0
  44. decktile-0.2.0.dist-info/WHEEL +4 -0
  45. decktile-0.2.0.dist-info/entry_points.txt +6 -0
  46. decktile-0.2.0.dist-info/licenses/LICENSE +674 -0
@@ -0,0 +1,65 @@
1
+ """markdown-it inline rule for lucide icons: {icon:star stroke=#64d039}"""
2
+
3
+ import re
4
+ from markdown_it.rules_inline import StateInline
5
+
6
+
7
+ _ICON_RE = re.compile(r'\{icon:(\S+)\s*([^}]*)\}')
8
+
9
+
10
+ def render_icon(name: str, attrs: dict[str, str] | None = None) -> str:
11
+ """Render a lucide icon to inline SVG. Shared by plugin and fenced preprocessor."""
12
+ if not name:
13
+ return ""
14
+ try:
15
+ from lucide import lucide_icon
16
+ kwargs = dict(attrs or {})
17
+ if "size" in kwargs:
18
+ v = kwargs.pop("size")
19
+ kwargs["height"] = kwargs["width"] = v
20
+ has_explicit_size = "height" in kwargs or "width" in kwargs
21
+ if not has_explicit_size:
22
+ kwargs["height"] = "1em"
23
+ svg = lucide_icon(name, **kwargs)
24
+ if not has_explicit_size:
25
+ svg = svg.replace('<svg', '<svg style="height:1em;width:auto;vertical-align:middle"', 1)
26
+ return svg
27
+ except Exception:
28
+ return ""
29
+
30
+
31
+ def _parse_opts(opts: str) -> dict[str, str]:
32
+ attrs = {}
33
+ for a in re.finditer(r'(\w+)="([^"]*)"', opts):
34
+ attrs[a.group(1)] = a.group(2)
35
+ for a in re.finditer(r'(\w+)=(\S+)', opts):
36
+ if a.group(1) not in attrs:
37
+ attrs[a.group(1)] = a.group(2)
38
+ return attrs
39
+
40
+
41
+ def lucide_icon_inline(state: StateInline, silent: bool = False) -> bool:
42
+ m = _ICON_RE.match(state.src[state.pos:])
43
+ if not m:
44
+ return False
45
+
46
+ if not silent:
47
+ name = m.group(1)
48
+ attrs = _parse_opts(m.group(2))
49
+ token = state.push("lucide_icon", "svg", 0)
50
+ token.attrSet("name", name)
51
+ for k, v in attrs.items():
52
+ token.attrSet(k, v)
53
+
54
+ state.pos += len(m.group(0))
55
+ return True
56
+
57
+
58
+ def _render_lucide_token(self, tokens, idx, options, env) -> str:
59
+ attrs = dict(tokens[idx].attrs or {})
60
+ return render_icon(attrs.pop("name", ""), attrs)
61
+
62
+
63
+ def lucide_plugin(md):
64
+ md.inline.ruler.before("image", "lucide_icon", lucide_icon_inline)
65
+ md.add_render_rule("lucide_icon", _render_lucide_token)
@@ -0,0 +1,26 @@
1
+ """Lucide icon font: name -> glyph codepoint, parsed from the vendored
2
+ lucide.css codepoint map (src/decktile/assets/lucide/VENDORED.md).
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import re
8
+ from functools import lru_cache
9
+ from pathlib import Path
10
+
11
+ FONT_DIR = Path(__file__).parent.parent / "assets" / "lucide"
12
+ FONT_PATH = FONT_DIR / "lucide.ttf"
13
+
14
+ _CODEPOINT_RE = re.compile(r'\.icon-([a-z0-9-]+)::before\s*\{\s*content:\s*"\\e([0-9a-f]+)"\s*;')
15
+
16
+
17
+ @lru_cache(maxsize=1)
18
+ def _codepoints() -> dict[str, str]:
19
+ css = (FONT_DIR / "lucide.css").read_text(encoding="utf-8")
20
+ return {name: chr(int("e" + hexcode, 16)) for name, hexcode in _CODEPOINT_RE.findall(css)}
21
+
22
+
23
+ def icon_glyph(name: str) -> str | None:
24
+ """The single glyph character for a lucide icon name, or None if the
25
+ vendored font doesn't have it."""
26
+ return _codepoints().get(name)
File without changes
@@ -0,0 +1,287 @@
1
+ """HTML renderer for decktile."""
2
+
3
+ import os
4
+ import subprocess
5
+ import tempfile
6
+ from pathlib import Path
7
+ from jinja2 import Environment, FileSystemLoader
8
+ from pygments import highlight
9
+ from pygments.lexers import get_lexer_by_name, TextLexer
10
+ from pygments.formatters import HtmlFormatter
11
+ from pygments.util import ClassNotFound
12
+
13
+ from decktile.parser.ast import Document
14
+ from decktile.render.ir import build_ir, SlideIR, Elem
15
+ from decktile.plugins.layouts import KNOWN_LAYOUTS
16
+
17
+ _pygments_style = "default"
18
+
19
+
20
+ _BRAND_ICON_SVGS = {
21
+ "linkedin": (
22
+ '<svg class="speaker-icon" xmlns="http://www.w3.org/2000/svg" '
23
+ 'viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" '
24
+ 'stroke-linecap="round" stroke-linejoin="round" '
25
+ 'style="height:1em;width:auto;vertical-align:middle">'
26
+ '<path d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z"/>'
27
+ '<rect width="4" height="12" x="2" y="9"/>'
28
+ '<circle cx="4" cy="4" r="2"/></svg>'
29
+ ),
30
+ }
31
+
32
+ TEMPLATE_DIR = Path(__file__).parent / "templates"
33
+ THEME_DIR = Path(__file__).parent.parent / "themes"
34
+ _env = Environment(loader=FileSystemLoader(str(TEMPLATE_DIR)), autoescape=False)
35
+
36
+
37
+ def default_theme() -> str:
38
+ p = THEME_DIR / "default.css"
39
+ return p.read_text(encoding="utf-8") if p.exists() else ""
40
+
41
+
42
+ def load_theme(name: str) -> str:
43
+ """Load a named theme CSS file. Falls back to default if not found."""
44
+ p = THEME_DIR / f"{name}.css"
45
+ return p.read_text(encoding="utf-8") if p.exists() else default_theme()
46
+
47
+
48
+ def base_css() -> str:
49
+ p = TEMPLATE_DIR / "base.css"
50
+ return p.read_text(encoding="utf-8") if p.exists() else ""
51
+
52
+
53
+ def render(doc: Document, theme_css: str, assembled_css: str, dims: tuple[int, int], logo: str = "") -> str:
54
+ from decktile.render.seaborn import set_palette
55
+
56
+ global _pygments_style
57
+ _pygments_style = doc.meta.pygments_style or "default"
58
+ set_palette(doc.meta.seaborn_theme)
59
+
60
+ ir_slides = build_ir(doc, base_css(), theme_css)
61
+ return render_from_ir(ir_slides, doc, assembled_css, dims)
62
+
63
+
64
+ def render_from_ir(ir_slides: list[SlideIR], doc: Document, assembled_css: str,
65
+ dims: tuple[int, int]) -> str:
66
+ """Render already-built IR slides to the full HTML page.
67
+
68
+ Split out from render() so the ODP geometry pass can render the exact
69
+ same IR (same data-decktile-id values) it will later annotate.
70
+ """
71
+ slides = []
72
+ for i, slide in enumerate(ir_slides):
73
+ children = _render_slide(slide)
74
+ slides.append({
75
+ "id": slide.id, "num": i + 1, "layout": slide.layout, "children": children,
76
+ "notes": slide.notes, "footer": doc.meta.footer or "", "paginate": doc.meta.paginate or False,
77
+ "variant": slide.variant or doc.meta.theme_variant,
78
+ "transition": slide.transition or doc.meta.transition or "",
79
+ })
80
+
81
+ return _env.get_template("shell.html").render(
82
+ title=doc.meta.title or "Presentation",
83
+ slide_w=dims[0], slide_h=dims[1],
84
+ css=assembled_css, slides=slides,
85
+ variant=doc.meta.theme_variant or "light",
86
+ autoplay=doc.meta.autoplay,
87
+ )
88
+
89
+
90
+ def _render_slide(slide: SlideIR) -> str:
91
+ elems = list(slide.elements)
92
+ heading_html = ""
93
+ if elems and elems[0].kind == "heading":
94
+ heading_html = _render_elem(elems[0])
95
+ elems = elems[1:]
96
+ if elems and elems[0].kind == "subtitle":
97
+ heading_html += "\n" + _render_elem(elems[0])
98
+ elems = elems[1:]
99
+
100
+ side_html = ""
101
+ for e in list(elems):
102
+ if e.kind == "side-image":
103
+ side_html = f'<div class="side-image" data-decktile-id="{e.id}"><img src="{_escape(e.src)}" alt=""></div>'
104
+ elems.remove(e)
105
+
106
+ body_html = "\n".join(filter(None, (_render_elem(e) for e in elems)))
107
+ if body_html.strip():
108
+ heading_html += '\n<div class="slide-body">\n' + body_html + '\n</div>'
109
+ if side_html:
110
+ heading_html += side_html
111
+ return heading_html or body_html
112
+
113
+
114
+ def _render_elem(e: Elem) -> str:
115
+ sid = f' data-decktile-id="{e.id}"'
116
+ if e.kind == "heading":
117
+ tag = f"h{e.level}"
118
+ return f"<{tag}{sid}>{e.content}</{tag}>"
119
+ elif e.kind == "text":
120
+ return f"<p{sid}>{e.content}</p>" if e.content else ""
121
+ elif e.kind == "quote":
122
+ return f'<div class="quote"{sid}>{e.content}</div>' if e.content else ""
123
+ elif e.kind == "code":
124
+ if e.language == "mermaid":
125
+ return _render_mermaid(e.content, e.svg, sid)
126
+ if e.language == "seaborn":
127
+ return _render_seaborn_html(e.content, e.svg, sid)
128
+ if e.language == "pdg":
129
+ return _render_pdg_html(e.content, e.svg, sid)
130
+ return _highlight_code(e.content, e.language, sid)
131
+ elif e.kind == "list":
132
+ s = f"<ul{sid}>\n"
133
+ for item in e.items:
134
+ s += f"<li>{item}</li>\n"
135
+ s += "</ul>"
136
+ return s
137
+ elif e.kind == "table":
138
+ # cells are inline HTML rendered by the parser (markdown-it), like
139
+ # card text -- escaping them again showed '&gt;' as '&amp;gt;'
140
+ s = f"<table{sid}>\n<thead>\n<tr>"
141
+ for h in e.headers:
142
+ s += f"<th>{h}</th>"
143
+ s += "</tr>\n</thead>\n<tbody>\n"
144
+ for row in e.rows:
145
+ s += "<tr>"
146
+ for cell in row:
147
+ s += f"<td>{cell}</td>"
148
+ s += "</tr>\n"
149
+ s += "</tbody>\n</table>"
150
+ return s
151
+ elif e.kind == "grid":
152
+ if e.layout:
153
+ cls = f"{e.layout}"
154
+ if "compare" in cls:
155
+ # children: col-left, arrow, col-right
156
+ parts = "\n".join(filter(None, (_render_elem(c) for c in e.children)))
157
+ return f'<div class="{cls}"{sid}>\n{parts}\n</div>'
158
+ children = "\n".join(filter(None, (_render_elem(c) for c in e.children)))
159
+ return f'<div class="{cls}"{sid}>\n{children}\n</div>'
160
+ cols = e.cols or len(e.children) or 2
161
+ style = f"grid-template-columns: repeat({cols}, 1fr); gap: 16px;"
162
+ cls = "grid"
163
+ if e.class_:
164
+ cls += f" {e.class_}"
165
+ children = "\n".join(filter(None, (_render_elem(c) for c in e.children)))
166
+ return f'<div class="{cls}" style="{style}"{sid}>\n{children}\n</div>'
167
+ elif e.kind == "card":
168
+ cls = "card"
169
+ if e.tag:
170
+ cls += f" tag-{e.tag}"
171
+ if e.class_:
172
+ cls += f" {e.class_}"
173
+ s = f'<div class="{cls}"{sid}>\n'
174
+ if e.attrs.get("label"):
175
+ s += f'<span class="timeline-label">{_escape(e.attrs["label"])}</span>\n'
176
+ # header/body are inline HTML from fenced._expand_markdown (already
177
+ # escaped by markdown-it): escaping again turned '>80%' into '&amp;gt;80%'
178
+ if e.header:
179
+ s += f"<h3>{e.header}</h3>\n"
180
+ for line in e.body:
181
+ if line.startswith("<ul") or line.startswith("<ol"):
182
+ s += line + "\n"
183
+ else:
184
+ s += f"<p>{line}</p>\n"
185
+ for child in e.children:
186
+ s += _render_elem(child) + "\n"
187
+ s += "</div>"
188
+ return s
189
+ elif e.kind == "arrow":
190
+ return f'<div class="card-arrow"{sid}>{e.content}</div>'
191
+ elif e.kind == "row":
192
+ children = "\n".join(filter(None, (_render_elem(c) for c in e.children)))
193
+ return f'<div class="row"{sid}>\n{children}\n</div>'
194
+ elif e.kind == "notes":
195
+ cls = "notes"
196
+ if e.tag:
197
+ cls += f" tag-{e.tag}"
198
+ return f'<div class="{cls}"{sid}>{e.content}</div>'
199
+ elif e.kind == "speaker":
200
+ from decktile.plugins.lucide import render_icon
201
+ name = e.attrs.get("name", e.content)
202
+ role = e.attrs.get("role", "")
203
+ text = f"{_escape(name)}<br><span class=\"role\">{_escape(role)}</span>" if role else _escape(name)
204
+ links = []
205
+ contact_icons = {"github": "git-fork", "twitter": "bird", "email": "mail", "website": "globe"}
206
+ for key in ("github", "twitter", "email", "linkedin", "website"):
207
+ val = e.attrs.get(key, "")
208
+ if val:
209
+ icon = contact_icons.get(key, "link")
210
+ href = val if "://" in val else f"https://{val}" if key != "email" else f"mailto:{val}"
211
+ label = val.replace("https://", "").replace("mailto:", "")
212
+ svg = _BRAND_ICON_SVGS.get(key) or render_icon(icon, {"cls": "speaker-icon"})
213
+ links.append(f'<a href="{_escape(href)}" target="_blank">{svg} {_escape(label)}</a>')
214
+ if links:
215
+ text += '<br><span class="speaker-links">' + "".join(links) + "</span>"
216
+ label = e.attrs.get("contributor")
217
+ if label:
218
+ return f'<div class="speaker contributor"{sid}><span class="contributor-label">{_escape(label)}</span><br>{text}</div>'
219
+ return f'<div class="speaker"{sid}>{text}</div>'
220
+ elif e.kind in ("kicker", "subtitle", "tiny"):
221
+ return f'<p class="{e.kind}"{sid}>{e.content}</p>'
222
+ elif e.kind == "video":
223
+ poster = _video_poster(e.src)
224
+ return (f'<figure class="video"{sid}>'
225
+ f'<video controls preload="metadata" src="{_escape(e.src)}" '
226
+ f'poster="{_escape(poster)}"></video>'
227
+ f'<img class="video-print" src="{_escape(poster)}" alt="">'
228
+ f'</figure>')
229
+ elif e.kind == "row-marker":
230
+ return ""
231
+ return ""
232
+
233
+
234
+ def _highlight_code(content: str, language: str, sid: str = "") -> str:
235
+ try:
236
+ lexer = get_lexer_by_name(language, stripall=True) if language else TextLexer()
237
+ except ClassNotFound:
238
+ lexer = TextLexer()
239
+ formatter = HtmlFormatter(nowrap=True, style=_pygments_style)
240
+ highlighted = highlight(content, lexer, formatter)
241
+ cls = f' class="highlight language-{language}"' if language else ' class="highlight"'
242
+ return f'<pre{cls}{sid}><code>{highlighted}</code></pre>'
243
+
244
+
245
+ def _pygments_css(style: str = "default") -> str:
246
+ formatter = HtmlFormatter(style=style)
247
+ css = formatter.get_style_defs('.slide .highlight')
248
+ return f"\n/* ---- pygments ---- */\n{css}\n"
249
+
250
+
251
+ def _render_seaborn_html(content: str, svg: str = "", sid: str = "") -> str:
252
+ if svg:
253
+ return f'<div class="seaborn-plot"{sid}>\n{svg}\n</div>'
254
+ return f'<pre class="seaborn-fallback"{sid}><code>{_escape(content)}</code></pre>'
255
+
256
+
257
+ def _render_mermaid(content: str, svg: str = "", sid: str = "") -> str:
258
+ if svg:
259
+ return f'<div class="mermaid"{sid}>\n{svg}\n</div>'
260
+ return f'<pre class="mermaid-fallback"{sid}><code>{_escape(content)}</code></pre>'
261
+
262
+
263
+ def _render_pdg_html(content: str, svg: str = "", sid: str = "") -> str:
264
+ if svg:
265
+ return f'<div class="pdg-plot"{sid}>\n{svg}\n</div>'
266
+ return f'<pre class="pdg-fallback"{sid}><code>{_escape(content)}</code></pre>'
267
+
268
+
269
+ def _normalize_viewbox(svg: str) -> str:
270
+ import re
271
+ m = re.search(r'viewBox="([\d.-]+)\s+([\d.-]+)\s+([\d.]+)\s+([\d.]+)"', svg)
272
+ if not m:
273
+ return svg
274
+ x, y, w, h = float(m.group(1)), float(m.group(2)), float(m.group(3)), float(m.group(4))
275
+ if x >= 0 and y >= 0:
276
+ return svg
277
+ new_w, new_h = w + abs(x), h + abs(y)
278
+ return svg.replace(m.group(0), f'viewBox="0 0 {new_w:g} {new_h:g}"', 1)
279
+
280
+
281
+ def _escape(s: str) -> str:
282
+ return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
283
+
284
+
285
+ def _video_poster(src: str) -> str:
286
+ """Poster/frame image for a video: same basename, .png extension."""
287
+ return src.rsplit(".", 1)[0] + ".png"