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.
- decktile/__init__.py +5 -0
- decktile/__main__.py +3 -0
- decktile/assets/lucide/VENDORED.md +21 -0
- decktile/assets/lucide/lucide.css +2193 -0
- decktile/assets/lucide/lucide.ttf +3 -0
- decktile/cli.py +269 -0
- decktile/parser/__init__.py +0 -0
- decktile/parser/ast.py +191 -0
- decktile/parser/markdown.py +289 -0
- decktile/plugins/cards.py +25 -0
- decktile/plugins/directives.py +56 -0
- decktile/plugins/fenced.py +228 -0
- decktile/plugins/layouts.py +69 -0
- decktile/plugins/lucide.py +65 -0
- decktile/plugins/lucide_font.py +26 -0
- decktile/render/__init__.py +0 -0
- decktile/render/html.py +287 -0
- decktile/render/ir.py +536 -0
- decktile/render/odf/__init__.py +4 -0
- decktile/render/odf/geometry.py +510 -0
- decktile/render/odf/shapes.py +719 -0
- decktile/render/odf/styles.py +828 -0
- decktile/render/odp.py +380 -0
- decktile/render/pdf.py +17 -0
- decktile/render/pdg.py +72 -0
- decktile/render/seaborn.py +155 -0
- decktile/render/templates/base.css +741 -0
- decktile/render/templates/decktile.js +371 -0
- decktile/render/templates/shell.html +106 -0
- decktile/seaborn_styles/__init__.py +5 -0
- decktile/seaborn_styles/ossummit_europe.py +30 -0
- decktile/theme/__init__.py +0 -0
- decktile/theme/colors.py +53 -0
- decktile/theme/loader.py +22 -0
- decktile/theme/parser.py +264 -0
- decktile/themes/default.css +146 -0
- decktile/themes/kcd_vietnam.css +159 -0
- decktile/themes/kubecon_china.css +213 -0
- decktile/themes/kubecon_japan.css +156 -0
- decktile/themes/ossummit_europe.css +196 -0
- decktile/themes/ossummit_korea.css +196 -0
- decktile-0.2.0.dist-info/METADATA +625 -0
- decktile-0.2.0.dist-info/RECORD +46 -0
- decktile-0.2.0.dist-info/WHEEL +4 -0
- decktile-0.2.0.dist-info/entry_points.txt +6 -0
- decktile-0.2.0.dist-info/licenses/LICENSE +674 -0
decktile/render/ir.py
ADDED
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
"""Slide intermediate representation.
|
|
2
|
+
|
|
3
|
+
Parses a Document AST into resolved, style-annotated slide structures
|
|
4
|
+
that both HTML and PPTX renderers consume. Eliminates duplicate node-walking
|
|
5
|
+
logic between renderers.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from decktile.parser.ast import (
|
|
14
|
+
Document, Slide, Heading, Paragraph, CodeBlock, Grid, Card,
|
|
15
|
+
Table, Quote, ListNode, AttrNode, Text, Strong, Emphasis,
|
|
16
|
+
Strikethrough, CodeSpan, Image, SoftBreak, LucideIcon, Arrow, Notes, Row,
|
|
17
|
+
)
|
|
18
|
+
from decktile.plugins.layouts import KNOWN_LAYOUTS
|
|
19
|
+
from decktile.theme.parser import parse_theme
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class ResolvedStyle:
|
|
24
|
+
"""Style resolved from a weasyprint box's computed CSS, in CSS px / hex."""
|
|
25
|
+
bg: str = "" # alpha-composited onto the parent's resolved bg
|
|
26
|
+
bg_alpha: float = 1.0
|
|
27
|
+
fg: str = "#333333"
|
|
28
|
+
border_color: str = ""
|
|
29
|
+
border_width: float = 0.0 # px
|
|
30
|
+
border_radius: float = 0.0 # px
|
|
31
|
+
font_family: str = ""
|
|
32
|
+
font_size_px: float = 18.0
|
|
33
|
+
font_weight: str = "normal"
|
|
34
|
+
font_italic: bool = False
|
|
35
|
+
text_align: str = "left"
|
|
36
|
+
bg_gradient: list[tuple[float, str, float]] | None = None # (offset, hex, alpha) stops
|
|
37
|
+
bg_angle_deg: float = 180.0
|
|
38
|
+
bg_image_url: str = "" # local file path of a CSS background-image, if any
|
|
39
|
+
bg_rgb01: tuple[float, float, float] = (1.0, 1.0, 1.0) # composited bg, for child compositing
|
|
40
|
+
padding_top: float = 0.0 # px
|
|
41
|
+
padding_right: float = 0.0
|
|
42
|
+
padding_bottom: float = 0.0
|
|
43
|
+
padding_left: float = 0.0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class BoxGeometry:
|
|
48
|
+
"""Border-box rect (cm, section-relative) plus resolved style for one Elem."""
|
|
49
|
+
x: float = 0.0
|
|
50
|
+
y: float = 0.0
|
|
51
|
+
w: float = 0.0
|
|
52
|
+
h: float = 0.0
|
|
53
|
+
style: ResolvedStyle = field(default_factory=ResolvedStyle)
|
|
54
|
+
# section::before/::after (logo/watermark): position/size/file resolved
|
|
55
|
+
# the same way as any other box, including CSS variable swaps like
|
|
56
|
+
# `.layout-title { --logo: var(--logo-dark) }` -- weasyprint's own
|
|
57
|
+
# cascade already picked the right file, no separate Python-side
|
|
58
|
+
# light/dark logo selection needed.
|
|
59
|
+
logo: "BoxGeometry | None" = None
|
|
60
|
+
watermark: "BoxGeometry | None" = None
|
|
61
|
+
# <footer>/.slide-num: real DOM elements injected by the HTML template
|
|
62
|
+
# (shell.html), not part of the parsed-markdown AST tree -- carry no
|
|
63
|
+
# data-decktile-id, so they're captured by tag/class instead of by-id
|
|
64
|
+
# like every other Elem, with their own plain-text content.
|
|
65
|
+
footer: "BoxGeometry | None" = None
|
|
66
|
+
slide_num: "BoxGeometry | None" = None
|
|
67
|
+
text: str = ""
|
|
68
|
+
# first-seen style per descendant tag within this Elem's own subtree:
|
|
69
|
+
# card h3/p, table th/td/tr:odd/tr:even, list li/li::marker.
|
|
70
|
+
substyles: dict[str, ResolvedStyle] = field(default_factory=dict)
|
|
71
|
+
# ::before/::after generated content with no AST node of its own (e.g.
|
|
72
|
+
# a timeline layout's .grid::before spine, .card::before/::after dot
|
|
73
|
+
# and connector stub) -- geometry-only decorations the ODP renderer
|
|
74
|
+
# draws as plain shapes, keyed by nothing but render order.
|
|
75
|
+
decorations: list["BoxGeometry"] = field(default_factory=list)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class Elem:
|
|
80
|
+
"""A single styled element on a slide."""
|
|
81
|
+
kind: str
|
|
82
|
+
content: str | list[Any] = "" # HTML (for HTML renderer)
|
|
83
|
+
text: str = "" # plain text (for PPTX renderer)
|
|
84
|
+
# Resolved style properties (for PPTX, informational for HTML)
|
|
85
|
+
font_size: int = 18
|
|
86
|
+
font_weight: str = "normal"
|
|
87
|
+
color: str = ""
|
|
88
|
+
bg: str = ""
|
|
89
|
+
accent: str = ""
|
|
90
|
+
muted: str = ""
|
|
91
|
+
# Structural fields
|
|
92
|
+
level: int = 0
|
|
93
|
+
language: str = ""
|
|
94
|
+
src: str = ""
|
|
95
|
+
alt: str = ""
|
|
96
|
+
header: str = ""
|
|
97
|
+
body: list[str] = field(default_factory=list)
|
|
98
|
+
headers: list[str] = field(default_factory=list)
|
|
99
|
+
rows: list[list[str]] = field(default_factory=list)
|
|
100
|
+
items: list[str] = field(default_factory=list)
|
|
101
|
+
tag: str = ""
|
|
102
|
+
class_: str = ""
|
|
103
|
+
cols: int = 0
|
|
104
|
+
children: list[Elem] = field(default_factory=list)
|
|
105
|
+
attrs: dict[str, str] = field(default_factory=dict)
|
|
106
|
+
layout: str = ""
|
|
107
|
+
inlines: list[Any] = field(default_factory=list)
|
|
108
|
+
item_inlines: list[list] = field(default_factory=list)
|
|
109
|
+
svg: str = ""
|
|
110
|
+
pdf: bytes = b""
|
|
111
|
+
id: str = "" # data-decktile-id, assigned in build_ir
|
|
112
|
+
box: BoxGeometry | None = None # filled by render/odf/geometry.py for ODP
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass
|
|
116
|
+
class SlideIR:
|
|
117
|
+
"""A fully resolved slide ready for rendering."""
|
|
118
|
+
id: str = ""
|
|
119
|
+
layout: str = "content"
|
|
120
|
+
elements: list[Elem] = field(default_factory=list)
|
|
121
|
+
notes: str = ""
|
|
122
|
+
variant: str = ""
|
|
123
|
+
transition: str = ""
|
|
124
|
+
|
|
125
|
+
def build_ir(doc: Document, base_css: str = "", theme_css: str = "") -> list[SlideIR]:
|
|
126
|
+
"""Convert a Document AST into a list of SlideIR objects."""
|
|
127
|
+
styles = resolve_styles(base_css, theme_css) if base_css else {}
|
|
128
|
+
slides = []
|
|
129
|
+
for idx, slide in enumerate(doc.slides):
|
|
130
|
+
layout = slide.layout
|
|
131
|
+
body_nodes = slide.children
|
|
132
|
+
|
|
133
|
+
heading_elem = None
|
|
134
|
+
subtitle_elem = None
|
|
135
|
+
if body_nodes and isinstance(body_nodes[0], Heading) and body_nodes[0].level <= 2:
|
|
136
|
+
heading_elem = _convert_node(body_nodes[0], styles)
|
|
137
|
+
body_nodes = body_nodes[1:]
|
|
138
|
+
if body_nodes and isinstance(body_nodes[0], AttrNode) and body_nodes[0].type == "subtitle":
|
|
139
|
+
subtitle_elem = _convert_node(body_nodes[0], styles)
|
|
140
|
+
body_nodes = body_nodes[1:]
|
|
141
|
+
|
|
142
|
+
if layout in KNOWN_LAYOUTS:
|
|
143
|
+
elements = _apply_layout_ir(body_nodes, layout, styles)
|
|
144
|
+
else:
|
|
145
|
+
elements = [_convert_node(n, styles) for n in body_nodes]
|
|
146
|
+
|
|
147
|
+
elements = _apply_rows(elements)
|
|
148
|
+
|
|
149
|
+
if heading_elem:
|
|
150
|
+
elements.insert(0, heading_elem)
|
|
151
|
+
if subtitle_elem:
|
|
152
|
+
elements.insert(1 if heading_elem else 0, subtitle_elem)
|
|
153
|
+
|
|
154
|
+
slides.append(SlideIR(id=f"s{idx}", layout=layout, elements=elements,
|
|
155
|
+
notes=slide.notes or "",
|
|
156
|
+
variant=slide.variant or "", transition=slide.transition or ""))
|
|
157
|
+
_assign_elem_ids(slides)
|
|
158
|
+
return slides
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _assign_elem_ids(slides: list[SlideIR]) -> None:
|
|
162
|
+
"""Assign a globally-unique data-decktile-id to every Elem (incl. nested children)."""
|
|
163
|
+
from itertools import count
|
|
164
|
+
counter = count(1)
|
|
165
|
+
|
|
166
|
+
def walk(elems: list[Elem]) -> None:
|
|
167
|
+
for e in elems:
|
|
168
|
+
e.id = f"e{next(counter)}"
|
|
169
|
+
if e.children:
|
|
170
|
+
walk(e.children)
|
|
171
|
+
|
|
172
|
+
for slide in slides:
|
|
173
|
+
walk(slide.elements)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def resolve_styles(base_css: str, theme_css: str) -> dict:
|
|
177
|
+
"""Parse CSS into resolved style properties for IR elements."""
|
|
178
|
+
s = parse_theme(base_css, theme_css)
|
|
179
|
+
s["ink_rgb_hex"] = _rgb_to_hex(s.get("ink_rgb", (51, 51, 51)))
|
|
180
|
+
s["muted_rgb_hex"] = _rgb_to_hex(s.get("muted_rgb", (119, 119, 119)))
|
|
181
|
+
s["accent_rgb_hex"] = _rgb_to_hex(s.get("accent_rgb", (2, 136, 209)))
|
|
182
|
+
return s
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def assemble_css(base_css: str, theme_css: str, dims: tuple[int, int],
|
|
186
|
+
logo: str = "", watermark: str = "", pygments_style: str = "default",
|
|
187
|
+
logo_dark: str = "", background: str = "") -> str:
|
|
188
|
+
"""Assemble the final CSS for HTML output: base + theme + logo + pygments."""
|
|
189
|
+
css = base_css.replace("SLIDE_W", str(dims[0])).replace("SLIDE_H", str(dims[1]))
|
|
190
|
+
css = css.replace("LOGO_CSS", "").replace("{logo_css}", "")
|
|
191
|
+
css = css.replace("THEME_CSS", theme_css).replace("{theme_css}", "")
|
|
192
|
+
css = css.replace("IMG_MAX_H", str(int(dims[1] * 0.70)))
|
|
193
|
+
# frontmatter images go in a :root rule AFTER the theme: injected into
|
|
194
|
+
# base.css's :root, a theme's own --watermark/--bg-image always won.
|
|
195
|
+
# "none" switches the theme's image off (e.g. another event's art).
|
|
196
|
+
images = {"--logo": logo, "--logo-dark": logo_dark,
|
|
197
|
+
"--watermark": watermark, "--bg-image": background}
|
|
198
|
+
decls = " ".join(f"{k}: {_css_url(v)};" for k, v in images.items() if v)
|
|
199
|
+
if decls:
|
|
200
|
+
css += f"\n:root {{ {decls} }}\n"
|
|
201
|
+
if pygments_style:
|
|
202
|
+
from decktile.render.html import _pygments_css
|
|
203
|
+
css += _pygments_css(pygments_style)
|
|
204
|
+
return css
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _css_url(v: str) -> str:
|
|
208
|
+
"""CSS value for a frontmatter image; "none" switches it off."""
|
|
209
|
+
return "none" if v == "none" else 'url("' + v + '")'
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _rgb_to_hex(rgb: tuple) -> str:
|
|
213
|
+
return f"#{rgb[0]:02x}{rgb[1]:02x}{rgb[2]:02x}"
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _apply_layout_ir(nodes: list, layout: str, styles: dict) -> list[Elem]:
|
|
217
|
+
"""Apply layout column wrapping at the IR level."""
|
|
218
|
+
extra = []
|
|
219
|
+
arrow_elem = None
|
|
220
|
+
if layout == "image-right":
|
|
221
|
+
left, right = _split_image_ir(nodes)
|
|
222
|
+
elif layout == "image-left":
|
|
223
|
+
right, left = _split_image_ir(nodes)
|
|
224
|
+
elif layout == "two-col":
|
|
225
|
+
left, right = _split_two_col_ir(nodes)
|
|
226
|
+
elif layout == "compare":
|
|
227
|
+
# Card | Arrow | Card → col-left, arrow, col-right
|
|
228
|
+
# Notes nodes stay as extra below
|
|
229
|
+
arrow_idx = next((i for i, n in enumerate(nodes) if isinstance(n, Arrow)), None)
|
|
230
|
+
extra = [n for n in nodes if isinstance(n, Notes)]
|
|
231
|
+
if arrow_idx is not None and arrow_idx > 0 and arrow_idx < len(nodes) - 1:
|
|
232
|
+
left = nodes[:arrow_idx]
|
|
233
|
+
right = nodes[arrow_idx + 1:]
|
|
234
|
+
right = [n for n in right if not isinstance(n, Notes)]
|
|
235
|
+
arrow_elem = _convert_node(nodes[arrow_idx], styles)
|
|
236
|
+
else:
|
|
237
|
+
left, right = _split_two_col_ir(nodes)
|
|
238
|
+
arrow_elem = None
|
|
239
|
+
else:
|
|
240
|
+
return [_convert_node(n, styles) for n in nodes]
|
|
241
|
+
|
|
242
|
+
children = []
|
|
243
|
+
if left:
|
|
244
|
+
children.append(_col_elem("left", left, styles))
|
|
245
|
+
if arrow_elem is not None:
|
|
246
|
+
children.append(arrow_elem)
|
|
247
|
+
if right:
|
|
248
|
+
children.append(_col_elem("right", right, styles))
|
|
249
|
+
cls = "layout-cols"
|
|
250
|
+
if layout == "compare":
|
|
251
|
+
cls += " compare"
|
|
252
|
+
result = [Elem(kind="grid", layout=cls, cols=0, children=children)]
|
|
253
|
+
if extra:
|
|
254
|
+
result.extend(_convert_node(n, styles) for n in extra)
|
|
255
|
+
return result
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _col_elem(side: str, nodes: list, styles: dict) -> Elem:
|
|
259
|
+
children = [_convert_node(n, styles) for n in nodes]
|
|
260
|
+
return Elem(kind="grid", layout=f"col-{side}", cols=0, children=children)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _split_image_ir(nodes: list) -> tuple[list, list]:
|
|
264
|
+
col_idx = _find_col(nodes)
|
|
265
|
+
if col_idx >= 0:
|
|
266
|
+
return nodes[:col_idx], nodes[col_idx + 1:]
|
|
267
|
+
for i, n in enumerate(nodes):
|
|
268
|
+
if isinstance(n, Paragraph) and any(isinstance(t, Image) for t in n.content):
|
|
269
|
+
return nodes[:i] + nodes[i + 1:], [n]
|
|
270
|
+
return nodes, []
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _split_two_col_ir(nodes: list) -> tuple[list, list]:
|
|
274
|
+
col_idx = _find_col(nodes)
|
|
275
|
+
if col_idx >= 0:
|
|
276
|
+
left, right = nodes[:col_idx], nodes[col_idx + 1:]
|
|
277
|
+
else:
|
|
278
|
+
left, right = nodes, []
|
|
279
|
+
# Group @row-separated elements into horizontal rows
|
|
280
|
+
left = _group_rows_ir(left)
|
|
281
|
+
right = _group_rows_ir(right)
|
|
282
|
+
return left, right
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _apply_rows(elements: list[Elem]) -> list[Elem]:
|
|
286
|
+
"""Wrap elements between @row markers into Row containers. Works on every slide."""
|
|
287
|
+
has_markers = any(e.kind == "row-marker" for e in elements)
|
|
288
|
+
if not has_markers:
|
|
289
|
+
return elements
|
|
290
|
+
result = []
|
|
291
|
+
buf = []
|
|
292
|
+
for e in elements:
|
|
293
|
+
if e.kind == "row-marker":
|
|
294
|
+
if buf:
|
|
295
|
+
result.append(Elem(kind="row", children=buf))
|
|
296
|
+
buf = []
|
|
297
|
+
continue
|
|
298
|
+
buf.append(e)
|
|
299
|
+
if buf:
|
|
300
|
+
result.append(Elem(kind="row", children=buf))
|
|
301
|
+
return result
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _group_rows_ir(nodes: list) -> list:
|
|
305
|
+
"""Group @row-separated elements into horizontal Rows."""
|
|
306
|
+
from decktile.parser.ast import AttrNode, Row
|
|
307
|
+
row_indices = [i for i, n in enumerate(nodes)
|
|
308
|
+
if isinstance(n, AttrNode) and n.type == "row"]
|
|
309
|
+
if not row_indices:
|
|
310
|
+
return list(nodes)
|
|
311
|
+
# Everything from first element to last (between @row markers) goes in Row
|
|
312
|
+
result = []
|
|
313
|
+
start = 0
|
|
314
|
+
for ri in row_indices:
|
|
315
|
+
if ri > start:
|
|
316
|
+
result.extend(nodes[start:ri])
|
|
317
|
+
start = ri + 1
|
|
318
|
+
if start < len(nodes):
|
|
319
|
+
result.extend(nodes[start:])
|
|
320
|
+
# Wrap if we collected multiple
|
|
321
|
+
if len(result) > 1:
|
|
322
|
+
return [Row(children=list(result))]
|
|
323
|
+
return result
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _find_col(nodes: list) -> int:
|
|
327
|
+
for i, n in enumerate(nodes):
|
|
328
|
+
if isinstance(n, AttrNode) and n.type == "col":
|
|
329
|
+
return i
|
|
330
|
+
return -1
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _normalize_viewbox(svg: str) -> str:
|
|
334
|
+
"""Normalize negative viewBox origin to 0,0, expanding dimensions."""
|
|
335
|
+
import re
|
|
336
|
+
m = re.search(r'viewBox="([\d.-]+)\s+([\d.-]+)\s+([\d.]+)\s+([\d.]+)"', svg)
|
|
337
|
+
if not m:
|
|
338
|
+
return svg
|
|
339
|
+
x, y, w, h = float(m.group(1)), float(m.group(2)), float(m.group(3)), float(m.group(4))
|
|
340
|
+
if x >= 0 and y >= 0:
|
|
341
|
+
return svg
|
|
342
|
+
new_w, new_h = w + abs(x), h + abs(y)
|
|
343
|
+
return svg.replace(m.group(0), f'viewBox="0 0 {new_w:g} {new_h:g}"', 1)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _convert_node(node, styles: dict) -> Elem:
|
|
347
|
+
"""Convert a single AST node to an IR element with resolved styles."""
|
|
348
|
+
base = Elem(kind="text", content="",
|
|
349
|
+
font_size=styles.get("font_body", 18),
|
|
350
|
+
color=styles.get("ink_rgb_hex", ""),
|
|
351
|
+
accent=styles.get("accent_rgb_hex", ""),
|
|
352
|
+
muted=styles.get("muted_rgb_hex", ""))
|
|
353
|
+
|
|
354
|
+
if isinstance(node, Heading):
|
|
355
|
+
fs = styles[f"font_h{node.level}"]
|
|
356
|
+
return Elem(kind="heading", content=_render_inline_html(node.content),
|
|
357
|
+
text=_render_inline_text(node.content),
|
|
358
|
+
inlines=node.content,
|
|
359
|
+
level=node.level, font_size=fs, color=base.color)
|
|
360
|
+
elif isinstance(node, Paragraph):
|
|
361
|
+
return Elem(kind="text", content=_render_inline_html(node.content),
|
|
362
|
+
text=_render_inline_text(node.content),
|
|
363
|
+
inlines=node.content,
|
|
364
|
+
font_size=base.font_size, color=base.color)
|
|
365
|
+
elif isinstance(node, CodeBlock):
|
|
366
|
+
fs = styles.get("font_code", 14)
|
|
367
|
+
svg = ""
|
|
368
|
+
pdf = b""
|
|
369
|
+
if node.language == "seaborn":
|
|
370
|
+
from decktile.render.seaborn import render_seaborn_svg
|
|
371
|
+
svg = render_seaborn_svg(node.content) or ""
|
|
372
|
+
elif node.language == "mermaid":
|
|
373
|
+
try:
|
|
374
|
+
from mermaidx import Diagram
|
|
375
|
+
d = Diagram(node.content)
|
|
376
|
+
svg = _normalize_viewbox(d.svg())
|
|
377
|
+
pdf = d.pdf()
|
|
378
|
+
except Exception:
|
|
379
|
+
svg = ""
|
|
380
|
+
pdf = b""
|
|
381
|
+
elif node.language == "pdg":
|
|
382
|
+
from decktile.render.pdg import render_pdg_svg
|
|
383
|
+
fam = styles.get("font_body_family", "")
|
|
384
|
+
size = styles.get("font_body", 18)
|
|
385
|
+
tags = styles.get("tag_colors", {})
|
|
386
|
+
svg = render_pdg_svg(node.content, font_family=fam, font_size=size,
|
|
387
|
+
tag_colors=tags,
|
|
388
|
+
blueprint=styles.get("pdg_blueprint", False)) or ""
|
|
389
|
+
return Elem(kind="code", content=node.content, text=node.content,
|
|
390
|
+
language=node.language, font_size=fs, svg=svg, pdf=pdf)
|
|
391
|
+
elif isinstance(node, ListNode):
|
|
392
|
+
items_html = [_render_inline_html(item) for item in node.items]
|
|
393
|
+
items_text = [_render_inline_text(item) for item in node.items]
|
|
394
|
+
fs = styles.get("font_li", 16)
|
|
395
|
+
return Elem(kind="list", content=items_html, text=">".join(items_text),
|
|
396
|
+
items=items_html,
|
|
397
|
+
item_inlines=[list(item) for item in node.items],
|
|
398
|
+
font_size=fs, color=base.color)
|
|
399
|
+
elif isinstance(node, Table):
|
|
400
|
+
return Elem(kind="table", headers=node.headers, rows=node.rows)
|
|
401
|
+
elif isinstance(node, Quote):
|
|
402
|
+
fs = styles.get("font_quote", 18)
|
|
403
|
+
return Elem(kind="quote", content=_render_inline_html(node.content),
|
|
404
|
+
text=_render_inline_text(node.content),
|
|
405
|
+
inlines=node.content,
|
|
406
|
+
font_size=fs, color=base.muted, accent=base.accent)
|
|
407
|
+
elif isinstance(node, Grid):
|
|
408
|
+
children = [_convert_node(c, styles) for c in node.children]
|
|
409
|
+
return Elem(kind="grid", cols=node.cols, class_=node.class_ or "", children=children)
|
|
410
|
+
elif isinstance(node, Card):
|
|
411
|
+
# a ```pdg fence inside ::: card {blueprint} draws in the card's palette
|
|
412
|
+
child_styles = ({**styles, "pdg_blueprint": True}
|
|
413
|
+
if "blueprint" in (node.class_ or "").split() else styles)
|
|
414
|
+
children = [_convert_node(c, child_styles) for c in node.children]
|
|
415
|
+
return Elem(kind="card", header=node.header, body=node.body,
|
|
416
|
+
tag=node.tag or "", class_=node.class_ or "",
|
|
417
|
+
children=children,
|
|
418
|
+
attrs={"label": node.label} if node.label else {},
|
|
419
|
+
font_size=base.font_size, color=base.color)
|
|
420
|
+
elif isinstance(node, Arrow):
|
|
421
|
+
content = node.content.strip()
|
|
422
|
+
if not content:
|
|
423
|
+
content = "\u2192"
|
|
424
|
+
# Check if content is an image reference
|
|
425
|
+
if content.startswith("!["):
|
|
426
|
+
from markdown_it import MarkdownIt
|
|
427
|
+
md = MarkdownIt()
|
|
428
|
+
tokens = md.parseInline(content)
|
|
429
|
+
html = ""
|
|
430
|
+
for t in tokens:
|
|
431
|
+
if t.type == "image":
|
|
432
|
+
html = f'<img src="{t.attrs.get("src", "")}" alt="{t.attrs.get("alt", "arrow")}">'
|
|
433
|
+
break
|
|
434
|
+
if html:
|
|
435
|
+
return Elem(kind="arrow", content=html, text=html, font_size=20,
|
|
436
|
+
color=base.muted)
|
|
437
|
+
return Elem(kind="arrow", content=content, text=content, font_size=20,
|
|
438
|
+
color=base.muted)
|
|
439
|
+
elif isinstance(node, Row):
|
|
440
|
+
children = [_convert_node(c, styles) for c in node.children]
|
|
441
|
+
return Elem(kind="row", children=children)
|
|
442
|
+
elif isinstance(node, Notes):
|
|
443
|
+
return Elem(kind="notes", content=node.content, tag=node.tag or "")
|
|
444
|
+
elif isinstance(node, AttrNode):
|
|
445
|
+
if node.type in ("speaker", "contributor"):
|
|
446
|
+
name = node.attrs.get("name", node.value)
|
|
447
|
+
role = node.attrs.get("role", "")
|
|
448
|
+
attrs = node.attrs
|
|
449
|
+
if node.type == "contributor":
|
|
450
|
+
attrs = {**attrs, "contributor": attrs.get("label", "Contributor")}
|
|
451
|
+
text = f"{name}\n{role}" if role else name
|
|
452
|
+
return Elem(kind="speaker", content=_escape(node.value), text=text,
|
|
453
|
+
attrs=attrs,
|
|
454
|
+
font_size=styles.get("font_speaker", 18), color=base.color)
|
|
455
|
+
elif node.type in ("kicker", "subtitle", "tiny"):
|
|
456
|
+
font_key, default_fs, color = {
|
|
457
|
+
"kicker": ("font_kicker", 14, base.accent),
|
|
458
|
+
"subtitle": ("font_subtitle", 32, base.muted),
|
|
459
|
+
"tiny": ("font_small", 13, base.muted),
|
|
460
|
+
}[node.type]
|
|
461
|
+
content, text = _inline_markdown(node.value)
|
|
462
|
+
return Elem(kind=node.type, content=content, text=text,
|
|
463
|
+
font_size=styles.get(font_key, default_fs), color=color)
|
|
464
|
+
elif node.type == "side-image":
|
|
465
|
+
return Elem(kind="side-image", src=node.value, content="")
|
|
466
|
+
elif node.type == "video":
|
|
467
|
+
return Elem(kind="video", src=node.value, content="")
|
|
468
|
+
elif node.type == "row":
|
|
469
|
+
return Elem(kind="row-marker")
|
|
470
|
+
return Elem(kind="text", content=_escape(node.value), text=node.value)
|
|
471
|
+
return Elem(kind="text", content="", text="")
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _escape(s: str) -> str:
|
|
475
|
+
return s.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _inline_markdown(value: str) -> tuple[str, str]:
|
|
479
|
+
"""Directive text (``@subtitle`` etc.) as inline HTML (code, bold,
|
|
480
|
+
icons) plus plain text for renderers that can't use HTML (ODP). Was
|
|
481
|
+
escaped as plain text, so `code` showed literal backticks."""
|
|
482
|
+
import html
|
|
483
|
+
import re
|
|
484
|
+
|
|
485
|
+
from decktile.plugins.fenced import _expand_markdown
|
|
486
|
+
rendered = _expand_markdown(value)
|
|
487
|
+
return rendered, html.unescape(re.sub(r"<[^>]+>", "", rendered))
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _render_inline_html(nodes: list) -> str:
|
|
491
|
+
"""Render inline AST nodes to HTML (preserves formatting)."""
|
|
492
|
+
s = ""
|
|
493
|
+
for n in nodes:
|
|
494
|
+
if isinstance(n, Text):
|
|
495
|
+
s += _escape(n.content)
|
|
496
|
+
elif isinstance(n, Strong):
|
|
497
|
+
s += f"<strong>{_render_inline_html(n.children)}</strong>"
|
|
498
|
+
elif isinstance(n, Emphasis):
|
|
499
|
+
s += f"<em>{_render_inline_html(n.children)}</em>"
|
|
500
|
+
elif isinstance(n, Strikethrough):
|
|
501
|
+
s += f"<s>{_render_inline_html(n.children)}</s>"
|
|
502
|
+
elif isinstance(n, CodeSpan):
|
|
503
|
+
s += f"<code>{_escape(n.content)}</code>"
|
|
504
|
+
elif isinstance(n, Image):
|
|
505
|
+
title = f' title="{_escape(n.title)}"' if n.title else ""
|
|
506
|
+
s += f'<img src="{_escape(n.src)}" alt="{_escape(n.alt)}" decoding="async"{title}>'
|
|
507
|
+
elif isinstance(n, SoftBreak):
|
|
508
|
+
s += " "
|
|
509
|
+
elif isinstance(n, LucideIcon):
|
|
510
|
+
s += _render_lucide_inline(n.name, n.attrs)
|
|
511
|
+
return s
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _render_lucide_inline(name: str, attrs: dict) -> str:
|
|
515
|
+
"""Render a LucideIcon as inline SVG. Delegates to shared plugin renderer."""
|
|
516
|
+
from decktile.plugins.lucide import render_icon
|
|
517
|
+
return render_icon(name, attrs)
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _render_inline_text(nodes: list) -> str:
|
|
521
|
+
"""Render inline AST nodes to plain text (for PPTX use)."""
|
|
522
|
+
s = ""
|
|
523
|
+
for n in nodes:
|
|
524
|
+
if isinstance(n, Text):
|
|
525
|
+
s += n.content
|
|
526
|
+
elif isinstance(n, (Strong, Emphasis, Strikethrough)):
|
|
527
|
+
s += _render_inline_text(n.children)
|
|
528
|
+
elif isinstance(n, CodeSpan):
|
|
529
|
+
s += n.content
|
|
530
|
+
elif isinstance(n, Image):
|
|
531
|
+
s += f"[{n.alt or 'image'}]"
|
|
532
|
+
elif isinstance(n, SoftBreak):
|
|
533
|
+
s += " "
|
|
534
|
+
elif isinstance(n, LucideIcon):
|
|
535
|
+
s += f":{n.name}:"
|
|
536
|
+
return s
|