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,289 @@
1
+ """Markdown parser using markdown-it-py with plugin extensions."""
2
+
3
+ import re
4
+ import frontmatter
5
+ from markdown_it import MarkdownIt
6
+ from markdown_it.token import Token
7
+
8
+ from decktile.parser.ast import (
9
+ Document, Meta, Slide,
10
+ Heading, Paragraph, CodeBlock, Card, Grid, Table, Quote, ListNode, AttrNode,
11
+ Text, Strong, Emphasis, Strikethrough, CodeSpan, Image, SoftBreak, LucideIcon,
12
+ )
13
+ from decktile.plugins.fenced import extract_fenced, interleave_fences
14
+ from decktile.plugins.directives import preprocess_directives, extract_attrs, parse_attr_token
15
+ from decktile.plugins.lucide import lucide_plugin
16
+ from decktile.plugins.cards import group_cards
17
+
18
+ # Renders a table cell's inline tokens to HTML (bold, code, links, icons).
19
+ # Concatenating token .content dropped every inline markup.
20
+ _CELL_MD = MarkdownIt("gfm-like", {"html": True}).use(lucide_plugin)
21
+
22
+
23
+ def parse(input_text: str) -> Document:
24
+ post = frontmatter.loads(input_text)
25
+ raw_meta = dict(post.metadata)
26
+ style_raw = raw_meta.pop("style", "") or ""
27
+ logo_raw = raw_meta.pop("logo", "") or ""
28
+ logo_dark_raw = raw_meta.pop("logo_dark", "") or ""
29
+ watermark_raw = raw_meta.pop("watermark", "") or ""
30
+ background_raw = raw_meta.pop("background", "") or ""
31
+
32
+ meta = Meta(
33
+ theme=raw_meta.pop("theme", ""),
34
+ title=raw_meta.pop("title", ""),
35
+ footer=raw_meta.pop("footer", ""),
36
+ paginate=raw_meta.pop("paginate", False),
37
+ size=raw_meta.pop("size", "16:9"),
38
+ style=style_raw,
39
+ logo=logo_raw,
40
+ logo_dark=logo_dark_raw,
41
+ watermark=watermark_raw,
42
+ background=background_raw,
43
+ pygments_style=raw_meta.pop("pygments_style", "default"),
44
+ seaborn_theme=raw_meta.pop("seaborn_theme", None) or Meta.seaborn_theme,
45
+ theme_variant=raw_meta.pop("variant", Meta.theme_variant),
46
+ transition=raw_meta.pop("transition", Meta.transition),
47
+ autoplay=raw_meta.pop("autoplay", Meta.autoplay),
48
+ )
49
+
50
+ body = post.content
51
+ slides = []
52
+ for part in body.split("\n---\n"):
53
+ part = part.strip()
54
+ if not part:
55
+ continue
56
+ slide = _parse_slide(part)
57
+ if slide is not None:
58
+ slides.append(slide)
59
+
60
+ return Document(meta=meta, slides=slides)
61
+
62
+
63
+ def _parse_slide(content: str) -> Slide:
64
+ notes = ""
65
+ # Collect all HTML comments as speaker notes
66
+ note_parts = re.findall(r"<!--(?!attr:)(.*?)-->", content, flags=re.DOTALL)
67
+ if note_parts:
68
+ blocks = []
69
+ for p in note_parts:
70
+ raw_lines = p.splitlines()
71
+ is_single_line = len(raw_lines) <= 1
72
+ lines = [line.rstrip() for line in raw_lines]
73
+ while lines and not lines[0].strip():
74
+ lines.pop(0)
75
+ while lines and not lines[-1].strip():
76
+ lines.pop()
77
+ if not lines:
78
+ continue
79
+ if is_single_line:
80
+ lines[0] = lines[0].strip()
81
+ blocks.append("\n".join(lines))
82
+ notes = "\n\n".join(blocks)
83
+ # Strip comments from content
84
+ content = re.sub(r"<!--(?!attr:).*?-->", "", content, flags=re.DOTALL).strip()
85
+ content = preprocess_directives(content)
86
+
87
+ # Extract fenced blocks
88
+ content, fence_nodes = extract_fenced(content)
89
+
90
+ md = MarkdownIt("gfm-like", {"breaks": True, "html": True})
91
+ md.use(lucide_plugin)
92
+ tokens = md.parse(content)
93
+ nodes = _tokens_to_nodes(tokens)
94
+
95
+ nodes = interleave_fences(nodes, fence_nodes)
96
+ nodes = extract_attrs(nodes)
97
+ nodes = group_cards(nodes)
98
+
99
+ layout = _detect_layout(nodes)
100
+ variant = ""
101
+ transition = ""
102
+ hidden = False
103
+ for n in list(nodes):
104
+ if isinstance(n, AttrNode) and n.type == "layout":
105
+ layout = n.value.strip()
106
+ nodes.remove(n)
107
+ elif isinstance(n, AttrNode) and n.type == "variant":
108
+ variant = n.value.strip()
109
+ nodes.remove(n)
110
+ elif isinstance(n, AttrNode) and n.type == "transition":
111
+ transition = n.value.strip()
112
+ nodes.remove(n)
113
+ elif isinstance(n, AttrNode) and n.type in ("hidden", "hide"):
114
+ hidden = True
115
+ nodes.remove(n)
116
+
117
+ if hidden:
118
+ return None
119
+
120
+ return Slide(layout=layout, children=nodes, notes=notes, variant=variant, transition=transition)
121
+
122
+
123
+ def _tokens_to_nodes(tokens: list[Token]) -> list:
124
+ nodes = []
125
+ i = 0
126
+ while i < len(tokens):
127
+ token = tokens[i]
128
+ if token.type == "heading_open":
129
+ level = int(token.tag[1])
130
+ i += 1
131
+ content = []
132
+ while i < len(tokens) and tokens[i].type != "heading_close":
133
+ content.extend(_token_inline(tokens[i]))
134
+ i += 1
135
+ nodes.append(Heading(level=level, content=content))
136
+ i += 1
137
+ elif token.type == "paragraph_open":
138
+ i += 1
139
+ content = []
140
+ while i < len(tokens) and tokens[i].type != "paragraph_close":
141
+ content.extend(_token_inline(tokens[i]))
142
+ i += 1
143
+ if content:
144
+ nodes.append(Paragraph(content=content))
145
+ i += 1
146
+ elif token.type == "blockquote_open":
147
+ i += 1
148
+ content = []
149
+ while i < len(tokens) and tokens[i].type != "blockquote_close":
150
+ if tokens[i].type == "inline":
151
+ content.extend(_token_inline(tokens[i]))
152
+ i += 1
153
+ if content:
154
+ nodes.append(Quote(content=content))
155
+ i += 1
156
+ elif token.type == "table_open":
157
+ rows = []
158
+ cur = []
159
+ i += 1
160
+ while i < len(tokens) and tokens[i].type != "table_close":
161
+ t = tokens[i]
162
+ if t.type in ("th_open", "td_open"):
163
+ i += 1
164
+ cell = ""
165
+ while i < len(tokens) and tokens[i].type not in ("th_close", "td_close"):
166
+ if tokens[i].type == "inline":
167
+ cell += _CELL_MD.renderer.renderInline(
168
+ tokens[i].children or [], _CELL_MD.options, {})
169
+ else:
170
+ cell += tokens[i].content
171
+ i += 1
172
+ cur.append(cell.strip())
173
+ elif t.type == "tr_close":
174
+ if cur:
175
+ rows.append(cur)
176
+ cur = []
177
+ i += 1
178
+ if cur:
179
+ rows.append(cur)
180
+ if rows:
181
+ nodes.append(Table(headers=rows[0], rows=rows[1:]))
182
+ i += 1
183
+ elif token.type in ("bullet_list_open", "ordered_list_open"):
184
+ close_type = token.type.replace("_open", "_close")
185
+ items = []
186
+ i += 1
187
+ while i < len(tokens) and tokens[i].type != close_type:
188
+ if tokens[i].type == "list_item_open":
189
+ i += 1
190
+ inlines = []
191
+ while i < len(tokens) and tokens[i].type != "list_item_close":
192
+ if tokens[i].type == "inline":
193
+ inlines.extend(_token_inline(tokens[i]))
194
+ i += 1
195
+ items.append(inlines)
196
+ i += 1
197
+ else:
198
+ i += 1
199
+ if items:
200
+ nodes.append(ListNode(items=items))
201
+ i += 1
202
+ elif token.type == "inline":
203
+ content = _token_inline(token)
204
+ if content:
205
+ nodes.append(Paragraph(content=content))
206
+ i += 1
207
+ elif token.type == "html_block":
208
+ html = token.content.strip()
209
+ node = parse_attr_token(html)
210
+ if node:
211
+ nodes.append(node)
212
+ i += 1
213
+ elif token.type in ("fence", "code_block"):
214
+ lang = token.info.strip() if token.info else ""
215
+ nodes.append(CodeBlock(content=token.content, language=lang))
216
+ i += 1
217
+ else:
218
+ i += 1
219
+ return nodes
220
+
221
+
222
+ def _token_inline(token: Token) -> list:
223
+ if token.type == "inline":
224
+ return _walk_inline_children(token.children or [])
225
+ elif token.type == "text":
226
+ return [Text(content=token.content)]
227
+ elif token.type == "code_inline":
228
+ return [CodeSpan(content=token.content)]
229
+ elif token.type == "softbreak":
230
+ return [SoftBreak()]
231
+ elif token.type == "hardbreak":
232
+ return [SoftBreak()]
233
+ elif token.type == "image":
234
+ attrs = dict(token.attrs or {})
235
+ return [Image(src=attrs.get("src", ""), alt=token.content or "", title=attrs.get("title", ""))]
236
+ elif token.type == "lucide_icon" or token.type == "svg":
237
+ attrs = dict(token.attrs or {})
238
+ name = attrs.pop("name", "")
239
+ return [LucideIcon(name=name, attrs=attrs)]
240
+ return []
241
+
242
+
243
+ def _walk_inline_children(tokens: list) -> list:
244
+ """Walk inline token children, handling strong/em open/close pairs."""
245
+ result = []
246
+ i = 0
247
+ while i < len(tokens):
248
+ t = tokens[i]
249
+ if t.type == "strong_open":
250
+ i += 1
251
+ inner = []
252
+ while i < len(tokens) and tokens[i].type != "strong_close":
253
+ inner.extend(_token_inline(tokens[i]))
254
+ i += 1
255
+ result.append(Strong(children=inner))
256
+ elif t.type == "em_open":
257
+ i += 1
258
+ inner = []
259
+ while i < len(tokens) and tokens[i].type != "em_close":
260
+ inner.extend(_token_inline(tokens[i]))
261
+ i += 1
262
+ result.append(Emphasis(children=inner))
263
+ elif t.type == "s_open":
264
+ i += 1
265
+ inner = []
266
+ while i < len(tokens) and tokens[i].type != "s_close":
267
+ inner.extend(_token_inline(tokens[i]))
268
+ i += 1
269
+ result.append(Strikethrough(children=inner))
270
+ else:
271
+ result.extend(_token_inline(t))
272
+ i += 1
273
+ return result
274
+
275
+
276
+ def _detect_layout(nodes: list) -> str:
277
+ has_h1 = any(isinstance(n, Heading) and n.level == 1 for n in nodes)
278
+ has_kicker = any(isinstance(n, AttrNode) and n.type == "kicker" for n in nodes)
279
+ has_speaker = any(isinstance(n, AttrNode) and n.type == "speaker" for n in nodes)
280
+ if has_h1 or has_kicker or has_speaker:
281
+ return "title"
282
+ for n in nodes:
283
+ if isinstance(n, Grid):
284
+ if n.children and all(
285
+ isinstance(c, Card) and "metric" in (c.class_ or "") for c in n.children
286
+ ):
287
+ return f"metrics-{n.cols}" if n.cols else "metrics-2"
288
+ return f"grid-{n.cols}" if n.cols else "grid-2"
289
+ return "content"
@@ -0,0 +1,25 @@
1
+ """Card auto-grouping: consecutive Card nodes → Grid."""
2
+
3
+ from decktile.parser.ast import Card, Grid, Node
4
+
5
+
6
+ def group_cards(nodes: list[Node]) -> list[Node]:
7
+ """Auto-group consecutive Card nodes into a Grid."""
8
+ result = []
9
+ i = 0
10
+ while i < len(nodes):
11
+ if isinstance(nodes[i], Card):
12
+ cards = [nodes[i]]
13
+ j = i + 1
14
+ while j < len(nodes) and isinstance(nodes[j], Card):
15
+ cards.append(nodes[j])
16
+ j += 1
17
+ if len(cards) >= 2:
18
+ result.append(Grid(cols=len(cards), children=list(cards)))
19
+ else:
20
+ result.append(cards[0])
21
+ i = j
22
+ else:
23
+ result.append(nodes[i])
24
+ i += 1
25
+ return result
@@ -0,0 +1,56 @@
1
+ """Directive parsing: @kicker, @speaker, @subtitle, etc. → AttrNode."""
2
+
3
+ import re
4
+ from decktile.parser.ast import AttrNode, Paragraph, Text, Node
5
+
6
+
7
+ def preprocess_directives(content: str) -> str:
8
+ """Convert @directive lines to HTML comment markers."""
9
+ lines = content.split("\n")
10
+ result = []
11
+ for line in lines:
12
+ stripped = line.strip()
13
+ if stripped.startswith("@") and not stripped.startswith("@@"):
14
+ parts = stripped[1:].split(" ", 1)
15
+ typ = parts[0]
16
+ value = parts[1] if len(parts) > 1 else ""
17
+ result.append(f"<!--attr:{typ}:{value}-->")
18
+ else:
19
+ result.append(line)
20
+ return "\n".join(result)
21
+
22
+
23
+ def extract_attrs(nodes: list[Node]) -> list[Node]:
24
+ """Post-process: convert attr comment markers to AttrNodes."""
25
+ result = []
26
+ for node in nodes:
27
+ if isinstance(node, Paragraph) and len(node.content) == 1:
28
+ text = node.content[0].content if isinstance(node.content[0], Text) else ""
29
+ m = re.match(r"<!--attr:([\w-]+):(.*)-->", text)
30
+ if m:
31
+ typ, value = m.group(1), m.group(2)
32
+ attrs = _parse_attrs(value)
33
+ result.append(AttrNode(type=typ, value=value, attrs=attrs))
34
+ continue
35
+ result.append(node)
36
+ return result
37
+
38
+
39
+ def parse_attr_token(html: str) -> AttrNode | None:
40
+ """Parse a single <!--attr:...--> comment into an AttrNode."""
41
+ m = re.match(r"<!--attr:([\w-]+):(.*)-->", html)
42
+ if m:
43
+ typ, value = m.group(1), m.group(2)
44
+ return AttrNode(type=typ, value=value, attrs=_parse_attrs(value))
45
+ return None
46
+
47
+
48
+ def _parse_attrs(value: str) -> dict[str, str]:
49
+ """Parse key="val" and key=val patterns from a value string."""
50
+ attrs = {}
51
+ for ma in re.finditer(r'(\w+)="([^"]*)"', value):
52
+ attrs[ma.group(1)] = ma.group(2)
53
+ for ma in re.finditer(r'(\w+)=(\S+)', value):
54
+ if ma.group(1) not in attrs:
55
+ attrs[ma.group(1)] = ma.group(2)
56
+ return attrs
@@ -0,0 +1,228 @@
1
+ """Fenced block extraction: ::: card, ::: grid → AST nodes.
2
+
3
+ Card/grid ``{...}`` attributes: bare words → CSS classes, ``k=v`` → ``k-v`` classes.
4
+ Grid: ``cols=N`` and ``class=name`` used literally.
5
+ """
6
+
7
+ import re
8
+ from markdown_it import MarkdownIt
9
+ from decktile.parser.ast import Arrow, Card, CodeBlock, Grid, Node, Notes
10
+ from decktile.plugins.lucide import lucide_plugin
11
+
12
+ _CARD_MD = MarkdownIt("gfm-like", {"breaks": False, "html": True})
13
+ _CARD_MD.use(lucide_plugin)
14
+
15
+ _ICON_RE = re.compile(r'\{icon:(\S+)\s*([^}]*)\}')
16
+
17
+
18
+ def _expand_icons(text: str) -> str:
19
+ """Expand {icon:...} markers using the shared lucide plugin renderer."""
20
+ if not text or "{" not in text:
21
+ return text
22
+ from decktile.plugins.lucide import render_icon, _parse_opts
23
+ def _render(m):
24
+ return render_icon(m.group(1), _parse_opts(m.group(2)))
25
+ return _ICON_RE.sub(_render, text)
26
+
27
+
28
+ def extract_fenced(content: str) -> tuple[str, list[Node]]:
29
+ """Extract ::: fence blocks from content, returning cleaned text and nodes."""
30
+ lines = content.split("\n")
31
+ result = []
32
+ nodes = []
33
+ count = 0
34
+ i = 0
35
+ while i < len(lines):
36
+ t = lines[i].strip()
37
+ if t == ":::":
38
+ i += 1
39
+ continue
40
+ if t.startswith(":::") and not t.startswith("::::"):
41
+ rest = t[3:].strip()
42
+ typ = rest.split()[0].split("{")[0]
43
+ depth = 1
44
+ inner = []
45
+ j = i + 1
46
+ while j < len(lines) and depth > 0:
47
+ lt = lines[j].strip()
48
+ if lt.startswith(":::") and not lt.startswith("::::") and lt != ":::":
49
+ depth += 1
50
+ elif lt == ":::":
51
+ depth -= 1
52
+ if depth > 0:
53
+ inner.append(lines[j])
54
+ j += 1
55
+ inner_text = "\n".join(inner)
56
+
57
+ if typ == "card":
58
+ nodes.append(_parse_card(inner_text, rest))
59
+ elif typ == "grid":
60
+ nodes.append(_parse_grid(inner_text, rest))
61
+ elif typ == "arrow":
62
+ nodes.append(_parse_arrow(inner_text))
63
+ elif typ == "notes":
64
+ nodes.append(_parse_notes(inner_text, rest))
65
+
66
+ # blank lines around the marker: adjacent fences (cards with no
67
+ # blank line between them) would otherwise merge into one
68
+ # markdown paragraph, and interleave_fences restores one fence
69
+ # per marker paragraph -- the rest got appended after every
70
+ # other block, splitting a card group
71
+ result.extend(["", f"\u25caFENCE_{count}", ""])
72
+ count += 1
73
+ i = j
74
+ continue
75
+ result.append(lines[i])
76
+ i += 1
77
+
78
+ return "\n".join(result), nodes
79
+
80
+
81
+ def _parse_card(text: str, rest: str = "") -> Card:
82
+ header = ""
83
+ body = []
84
+ children = []
85
+
86
+ lines = text.strip().split("\n")
87
+ i = 0
88
+ while i < len(lines):
89
+ line = lines[i].strip()
90
+
91
+ if line.startswith("```"):
92
+ lang = line[3:].strip()
93
+ inner = []
94
+ i += 1
95
+ while i < len(lines) and not lines[i].strip().startswith("```"):
96
+ inner.append(lines[i])
97
+ i += 1
98
+ children.append(CodeBlock(content="\n".join(inner), language=lang))
99
+ i += 1 # skip closing ```
100
+ continue
101
+ elif line.startswith(":::") and not line.startswith("::::"):
102
+ depth = 1
103
+ inner = []
104
+ j = i + 1
105
+ while j < len(lines) and depth > 0:
106
+ lt = lines[j].strip()
107
+ if lt.startswith(":::") and not lt.startswith("::::") and lt != ":::":
108
+ depth += 1
109
+ elif lt == ":::":
110
+ depth -= 1
111
+ if depth > 0:
112
+ inner.append(lines[j])
113
+ j += 1
114
+ _, nested = extract_fenced("\n".join(inner))
115
+ children.extend(nested)
116
+ i = j
117
+ continue
118
+ elif line.startswith("### "):
119
+ header = _expand_markdown(line[4:])
120
+ elif line.startswith("- "):
121
+ items = []
122
+ while i < len(lines) and lines[i].strip().startswith("- "):
123
+ li = lines[i].strip()[2:]
124
+ items.append(f"<li>{_expand_markdown(li)}</li>")
125
+ i += 1
126
+ body.append(f"<ul>{''.join(items)}</ul>")
127
+ continue
128
+ elif line:
129
+ rendered = _expand_markdown(line)
130
+ if rendered.strip():
131
+ body.append(rendered)
132
+ i += 1
133
+
134
+ class_ = ""
135
+ tag = None
136
+ label = ""
137
+ raw = rest.split("{", 1)[1].rstrip("}") if "{" in rest else ""
138
+ for attr in raw.split(","):
139
+ attr = attr.strip()
140
+ if not attr:
141
+ continue
142
+ if "=" in attr:
143
+ k, v = attr.split("=", 1)
144
+ k, v = k.strip(), v.strip().strip('"')
145
+ if k == "tag":
146
+ tag = v
147
+ elif k == "label":
148
+ label = v
149
+ else:
150
+ class_ = (class_ + f" {k}-{v}").strip()
151
+ else:
152
+ class_ = (class_ + " " + attr).strip() if class_ else attr
153
+
154
+ # ponytail: metric cards: first non-empty line is the value, rest is the label
155
+ if "metric" in class_ and not header and body:
156
+ header = body.pop(0)
157
+
158
+ return Card(header=header, body=body, children=children, tag=tag, class_=class_, label=label)
159
+
160
+
161
+ def _expand_markdown(text: str) -> str:
162
+ """Render inline markdown (bold, italic, code, images, lucide icons, links)."""
163
+ if not text:
164
+ return text
165
+ text = _expand_icons(text)
166
+ return _CARD_MD.renderInline(text)
167
+
168
+
169
+ def _parse_grid(inner_text: str, rest: str) -> Grid:
170
+ _, children = extract_fenced(inner_text)
171
+ raw = " ".join(rest.split()[1:]).strip("{}")
172
+ cols = 0
173
+ class_ = ""
174
+ for attr in raw.split(","):
175
+ attr = attr.strip()
176
+ if not attr:
177
+ continue
178
+ if "=" in attr:
179
+ k, v = attr.split("=", 1)
180
+ k, v = k.strip(), v.strip().strip('"')
181
+ if k == "cols":
182
+ cols = int(v)
183
+ elif k == "class":
184
+ class_ = v
185
+ else:
186
+ class_ = (class_ + " " + attr).strip() if class_ else attr
187
+ if cols == 0:
188
+ cols = len(children) or 2
189
+ return Grid(cols=cols, class_=class_, children=children)
190
+
191
+
192
+ def _parse_arrow(text: str) -> Arrow:
193
+ return Arrow(content=_expand_icons(text.strip()) or "\u2192")
194
+
195
+
196
+ def _parse_notes(text: str, rest: str) -> Notes:
197
+ tag = ""
198
+ raw = rest.split("{", 1)[1].rstrip("}") if "{" in rest else ""
199
+ for attr in raw.split(","):
200
+ attr = attr.strip()
201
+ if "=" in attr:
202
+ k, v = attr.split("=", 1)
203
+ if k.strip() == "tag":
204
+ tag = v.strip().strip('"')
205
+ # README documents `> quote` lines inside notes; the callout is rendered
206
+ # inline, so the blockquote marker otherwise showed as a literal '>'
207
+ text = "\n".join(re.sub(r"^\s*>\s?", "", line) for line in text.strip().splitlines())
208
+ return Notes(content=_expand_markdown(text), tag=tag or None)
209
+
210
+
211
+ def interleave_fences(nodes: list[Node], fence_nodes: list[Node]) -> list[Node]:
212
+ """Replace FENCE marker paragraphs with actual fence nodes."""
213
+ from decktile.parser.ast import Paragraph, Text
214
+ result = []
215
+ fi = 0
216
+ for node in nodes:
217
+ if isinstance(node, Paragraph) and node.content:
218
+ text = node.content[0].content if isinstance(node.content[0], Text) else ""
219
+ if text.startswith("\u25caFENCE_"):
220
+ if fi < len(fence_nodes):
221
+ result.append(fence_nodes[fi])
222
+ fi += 1
223
+ continue
224
+ result.append(node)
225
+ while fi < len(fence_nodes):
226
+ result.append(fence_nodes[fi])
227
+ fi += 1
228
+ return result
@@ -0,0 +1,69 @@
1
+ """Layout processing: applies predefined layouts to slide content."""
2
+
3
+ from decktile.parser.ast import Paragraph, AttrNode, Image
4
+
5
+ KNOWN_LAYOUTS = {"image-right", "image-left", "two-col", "compare", "ecosystem"}
6
+
7
+
8
+ def apply_layout(nodes: list, layout: str, render_fn) -> str:
9
+ """Wrap body nodes into columns per the layout definition."""
10
+ if layout not in KNOWN_LAYOUTS:
11
+ return "\n".join(filter(None, (render_fn(n) for n in nodes)))
12
+
13
+ if layout == "image-right":
14
+ left, right = _split_col_or_image(nodes, flip=False)
15
+ elif layout == "image-left":
16
+ left, right = _split_col_or_image(nodes, flip=True)
17
+ elif layout == "two-col":
18
+ left, right = _split_two_col_nodes(nodes)
19
+ elif layout == "compare":
20
+ left, right = _split_two_col_nodes(nodes)
21
+ else:
22
+ return "\n".join(filter(None, (render_fn(n) for n in nodes)))
23
+
24
+ left_html = "\n".join(filter(None, (render_fn(n) for n in left)))
25
+ right_html = "\n".join(filter(None, (render_fn(n) for n in right)))
26
+ parts = []
27
+ if left_html or right_html:
28
+ if layout == "compare":
29
+ parts.append('<div class="layout-cols compare">')
30
+ parts.append('<div class="col-left">\n' + left_html + '\n</div>')
31
+ parts.append('<div class="card-arrow">\u2192</div>')
32
+ parts.append('<div class="col-right">\n' + right_html + '\n</div>')
33
+ else:
34
+ parts.append('<div class="layout-cols">')
35
+ parts.append('<div class="col-left">\n' + left_html + '\n</div>')
36
+ if right_html:
37
+ parts.append('<div class="col-right">\n' + right_html + '\n</div>')
38
+ parts.append('</div>')
39
+ return "\n".join(parts)
40
+
41
+
42
+ def _split_col_or_image(nodes: list, flip: bool = False) -> tuple[list, list]:
43
+ col_idx = _find_col(nodes)
44
+ if col_idx >= 0:
45
+ left, right = nodes[:col_idx], nodes[col_idx + 1:]
46
+ return (right, left) if flip else (left, right)
47
+ return _split_image_nodes(nodes, flip)
48
+
49
+
50
+ def _split_image_nodes(nodes: list, flip: bool = False) -> tuple[list, list]:
51
+ for i, n in enumerate(nodes):
52
+ if isinstance(n, Paragraph) and any(isinstance(t, Image) for t in n.content):
53
+ left, right = nodes[:i] + nodes[i + 1:], [n]
54
+ return (right, left) if flip else (left, right)
55
+ return (nodes, []) if not flip else ([], nodes)
56
+
57
+
58
+ def _split_two_col_nodes(nodes: list) -> tuple[list, list]:
59
+ col_idx = _find_col(nodes)
60
+ if col_idx >= 0:
61
+ return nodes[:col_idx], nodes[col_idx + 1:]
62
+ return nodes, []
63
+
64
+
65
+ def _find_col(nodes: list) -> int:
66
+ for i, n in enumerate(nodes):
67
+ if isinstance(n, AttrNode) and n.type == "col":
68
+ return i
69
+ return -1