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
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
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
@@ -0,0 +1,4 @@
1
+ """ODP renderer package -- geometry, styles, and shapes modules."""
2
+ from decktile.render.odf import geometry, shapes, styles
3
+
4
+ __all__ = ["geometry", "shapes", "styles"]