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,719 @@
1
+ """Elem.kind -> ODF Frame/shape(s), built from the geometry-resolved IR.
2
+
3
+ Positions/sizes come from ``Elem.box`` (already cm, section-relative, from
4
+ render/odf/geometry.py). Content is built from the Elem's own structured
5
+ fields (inlines/items/rows/children) -- real ODF semantics (text:list,
6
+ table:table, per-run text:span), not HTML stripped to plain text.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import dataclasses
12
+ import os
13
+ import re
14
+ from pathlib import Path
15
+ from typing import Any, Callable
16
+
17
+ from odfdo import (
18
+ Column, EllipseShape, Frame, List as OdfList, ListItem, Paragraph, RectangleShape, Row, Span,
19
+ Table,
20
+ )
21
+
22
+ from decktile.parser.ast import (
23
+ CodeSpan, Emphasis, Image as ASTImage, LucideIcon, SoftBreak, Strikethrough,
24
+ Strong, Text,
25
+ )
26
+ from decktile.render.ir import BoxGeometry, Elem, ResolvedStyle
27
+ from decktile.render.odf.styles import CM_PER_PX, StyleRegistry, px_to_cm, round_image_corners
28
+
29
+ AddFile = Callable[[str], str]
30
+
31
+
32
+ def render_elem(
33
+ elem: Elem, reg: StyleRegistry, add_file: AddFile, source_dir: Path | None,
34
+ global_tags: dict[str, ResolvedStyle],
35
+ ) -> list[Any]:
36
+ """Build every ODF shape for one Elem (and its children). []: not geometry-annotated."""
37
+ if elem.box is None:
38
+ return []
39
+ fn = _RENDERERS.get(elem.kind, _render_text_like)
40
+ shapes = fn(elem, reg, add_file, source_dir, global_tags)
41
+ for child in elem.children:
42
+ shapes.extend(render_elem(child, reg, add_file, source_dir, global_tags))
43
+ return shapes
44
+
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # geometry helpers
48
+ # ---------------------------------------------------------------------------
49
+
50
+
51
+ def _pos(box: BoxGeometry) -> tuple[str, str]:
52
+ return (f"{box.x:.3f}cm", f"{box.y:.3f}cm")
53
+
54
+
55
+ def _size(box: BoxGeometry) -> tuple[str, str]:
56
+ return (f"{max(box.w, 0.1):.3f}cm", f"{max(box.h, 0.1):.3f}cm")
57
+
58
+
59
+ def _apply_radius(frame: Frame, style: ResolvedStyle) -> None:
60
+ if style.border_radius > 0:
61
+ frame.set_attribute("draw:corner-radius", px_to_cm(style.border_radius))
62
+
63
+
64
+ def _decoration_shapes(box: BoxGeometry, reg: StyleRegistry) -> list[Any]:
65
+ """Draw a grid/card's captured decorations (timeline spine/dot/stub,
66
+ milestone name label) -- no AST node of their own, only geometry+style.
67
+ Sizes are not clamped like _size(): the spine/stubs are sub-0.1cm
68
+ hairlines and would render ~2x too thick."""
69
+ shapes: list[Any] = []
70
+ for deco in box.decorations:
71
+ if deco.text:
72
+ shapes.append(_label_frame(deco, reg))
73
+ continue
74
+ size = (f"{max(deco.w, 0.01):.3f}cm", f"{max(deco.h, 0.01):.3f}cm")
75
+ gfx = reg.graphic_style(deco.style, fill=True, border=True)
76
+ if deco.style.border_radius * CM_PER_PX >= min(deco.w, deco.h) / 2 - 0.01:
77
+ shapes.append(EllipseShape(style=gfx, position=_pos(deco), size=size))
78
+ continue
79
+ shape = RectangleShape(style=gfx, position=_pos(deco), size=size, anchor_type="page")
80
+ _apply_radius(shape, deco.style)
81
+ shapes.append(shape)
82
+ return shapes
83
+
84
+
85
+ def _label_frame(deco: BoxGeometry, reg: StyleRegistry) -> Frame:
86
+ """Milestone name: centred in a frame widened by 0.4cm per side so
87
+ LibreOffice's slightly wider font metrics never wrap the nowrap label."""
88
+ style = dataclasses.replace(deco.style, text_align="center")
89
+ pad = 0.4
90
+ frame = Frame.text_frame(
91
+ Paragraph(deco.text, style=reg.paragraph_style(style)),
92
+ style=reg.graphic_style(style, fill=False, border=False, vertical_align="middle"),
93
+ position=(f"{deco.x - pad:.3f}cm", f"{deco.y:.3f}cm"),
94
+ size=(f"{deco.w + 2 * pad:.3f}cm", f"{max(deco.h, 0.1):.3f}cm"),
95
+ anchor_type="page",
96
+ )
97
+ return frame
98
+
99
+
100
+ # ---------------------------------------------------------------------------
101
+ # rich text (inline runs)
102
+ # ---------------------------------------------------------------------------
103
+
104
+
105
+ def _flatten_text(children: list) -> str:
106
+ out = []
107
+ for c in children:
108
+ if isinstance(c, Text):
109
+ out.append(c.content)
110
+ elif isinstance(c, (Strong, Emphasis, Strikethrough)):
111
+ out.append(_flatten_text(c.children))
112
+ elif isinstance(c, CodeSpan):
113
+ out.append(c.content)
114
+ return "".join(out)
115
+
116
+
117
+ def _build_rich_paragraph(
118
+ inlines: list, reg: StyleRegistry, base: ResolvedStyle,
119
+ global_tags: dict[str, ResolvedStyle], para_style: str,
120
+ ) -> Paragraph:
121
+ p = Paragraph(style=para_style)
122
+ for node in inlines:
123
+ if isinstance(node, Text):
124
+ p.append(node.content)
125
+ elif isinstance(node, Strong):
126
+ name = reg.run_style(base.fg, base.font_family, base.font_size_px, True, False)
127
+ p.append(Span(_flatten_text(node.children), style=name))
128
+ elif isinstance(node, Emphasis):
129
+ name = reg.run_style(base.fg, base.font_family, base.font_size_px, False, True)
130
+ p.append(Span(_flatten_text(node.children), style=name))
131
+ elif isinstance(node, Strikethrough):
132
+ p.append(_flatten_text(node.children))
133
+ elif isinstance(node, CodeSpan):
134
+ code = global_tags.get("code")
135
+ family = code.font_family if code else base.font_family
136
+ name = reg.run_style(base.fg, family, base.font_size_px, False, False, mono=True)
137
+ p.append(Span(node.content, style=name))
138
+ elif isinstance(node, ASTImage):
139
+ p.append(f" [{node.alt or 'image'}] ")
140
+ elif isinstance(node, SoftBreak):
141
+ p.append(" ")
142
+ elif isinstance(node, LucideIcon):
143
+ from decktile.plugins.lucide_font import icon_glyph
144
+
145
+ glyph = icon_glyph(node.name)
146
+ if glyph:
147
+ color = _icon_accent_color(node.attrs.get("cls", ""), global_tags, base.fg)
148
+ reg.ensure_lucide_font()
149
+ run = reg.run_style(color, "lucide", base.font_size_px, False, False)
150
+ p.append(Span(glyph, style=run))
151
+ return p
152
+
153
+
154
+ def _plain_paragraph(text: str, para_style: str) -> Paragraph:
155
+ return Paragraph(text, style=para_style)
156
+
157
+
158
+ # ---------------------------------------------------------------------------
159
+ # renderers
160
+ # ---------------------------------------------------------------------------
161
+
162
+
163
+ def _render_text_like(
164
+ elem: Elem, reg: StyleRegistry, add_file: AddFile, source_dir: Path | None,
165
+ global_tags: dict[str, ResolvedStyle], *, border: bool = True,
166
+ ) -> list[Any]:
167
+ box = elem.box
168
+ non_break = [n for n in elem.inlines if not isinstance(n, SoftBreak)]
169
+ if non_break and all(isinstance(n, ASTImage) for n in non_break):
170
+ # a paragraph whose entire content is image(s): render as picture(s),
171
+ # not a text frame with "[alt]" placeholders.
172
+ return _render_inline_images(non_break, box, add_file, source_dir)
173
+ style = box.style
174
+ para_style = reg.paragraph_style(style)
175
+ content = (
176
+ _build_rich_paragraph(elem.inlines, reg, style, global_tags, para_style)
177
+ if elem.inlines else _plain_paragraph(_strip_html(elem.text or ""), para_style)
178
+ )
179
+ gfx = reg.graphic_style(style, fill=False, vertical_align="top", border=border)
180
+ frame = Frame.text_frame(
181
+ content, style=gfx, position=_pos(box), size=_size(box), anchor_type="page",
182
+ )
183
+ return [frame]
184
+
185
+
186
+ def _left_accent_bar(box: BoxGeometry, style: ResolvedStyle) -> Any | None:
187
+ """CSS ``border-left`` accent (quote/notes callouts) has no ODF
188
+ rectangle-outline equivalent -- a full ``draw:stroke`` paints all four
189
+ sides. Draw it as a thin vertical line instead."""
190
+ if not (style.border_color and style.border_width > 0):
191
+ return None
192
+ from odfdo import LineShape
193
+
194
+ bar = LineShape(
195
+ p1=(_pos(box)[0], _pos(box)[1]),
196
+ p2=(_pos(box)[0], f"{box.y + box.h:.3f}cm"),
197
+ )
198
+ bar.set_attribute("svg:stroke-color", style.border_color)
199
+ bar.set_attribute("svg:stroke-width", px_to_cm(style.border_width))
200
+ return bar
201
+
202
+
203
+ def _render_quote(
204
+ elem: Elem, reg: StyleRegistry, add_file: AddFile, source_dir: Path | None,
205
+ global_tags: dict[str, ResolvedStyle],
206
+ ) -> list[Any]:
207
+ """Text frame plus a left accent bar (CSS ``border-left``, no ODF equivalent)."""
208
+ shapes = _render_text_like(elem, reg, add_file, source_dir, global_tags, border=False)
209
+ bar = _left_accent_bar(elem.box, elem.box.style)
210
+ if bar is not None:
211
+ shapes.insert(0, bar)
212
+ return shapes
213
+
214
+
215
+ def _strip_html(text: str) -> str:
216
+ """Card/table/notes text comes from the parser pre-expanded to inline
217
+ HTML (bold, lucide icon SVG, etc. -- see plugins/fenced.py's
218
+ _expand_markdown/_expand_icons). ODF cell/paragraph text is literal,
219
+ not HTML: embedding it raw would print tag soup (confirmed: lucide
220
+ icon SVGs showing as literal `<svg>...` text in table cells). Strip
221
+ tags and decode entities (`&gt;`), then collapse whitespace: a
222
+ multi-line SVG icon (newline after `<svg ...>`, one per `<path/>`)
223
+ leaves literal `\\n` runs once its tags are gone, which `Paragraph`
224
+ then turns into leading `text:line-break` elements pushing a card
225
+ header down off the top of its box (confirmed: 5 icon newlines ->
226
+ 5 line breaks before the heading text)."""
227
+ import html
228
+ import re
229
+
230
+ stripped = html.unescape(re.sub(r"<[^>]+>", "", text))
231
+ return re.sub(r"\s+", " ", stripped).strip()
232
+
233
+
234
+ def _html_list_items(text: str) -> list[str] | None:
235
+ """Card body lines are parser-pre-expanded HTML (plugins/fenced.py's
236
+ _expand_markdown); a markdown bullet list inside a card body comes
237
+ through as one ``<ul><li>...</li><li>...</li></ul>`` string -- blanket
238
+ `_strip_html` on that concatenates every item with no separator
239
+ (confirmed: "YOLO...Runs on NPUs..." ran together as one line).
240
+ Returns each item's stripped text, or None if `text` isn't a list."""
241
+ import re
242
+
243
+ if not text.lstrip().startswith(("<ul", "<ol")):
244
+ return None
245
+ return [_strip_html(m) for m in re.findall(r"<li>(.*?)</li>", text, re.DOTALL)]
246
+
247
+
248
+ def _render_notes(
249
+ elem: Elem, reg: StyleRegistry, add_file: AddFile, source_dir: Path | None,
250
+ global_tags: dict[str, ResolvedStyle],
251
+ ) -> list[Any]:
252
+ box = elem.box
253
+ style = box.style
254
+ para_style = reg.paragraph_style(style)
255
+ p = _plain_paragraph(_strip_html(elem.content or ""), para_style)
256
+ gfx = reg.graphic_style(style, fill=True, vertical_align="middle", border=False)
257
+ frame = Frame.text_frame(p, style=gfx, position=_pos(box), size=_size(box), anchor_type="page")
258
+ _apply_radius(frame, style)
259
+ bar = _left_accent_bar(box, style)
260
+ return [bar, frame] if bar is not None else [frame]
261
+
262
+
263
+ def _highlighted_code_paragraph(
264
+ code: str, language: str, style: ResolvedStyle, reg: StyleRegistry, para_style: str,
265
+ ) -> Paragraph:
266
+ """Per-token colored Spans via the same pygments lexer/style the HTML
267
+ renderer uses (html.py's _highlight_code) -- previously code blocks were
268
+ one flat mono Paragraph with no token coloring at all."""
269
+ from pygments import lex
270
+ from pygments.lexers import TextLexer, get_lexer_by_name
271
+ from pygments.styles import get_style_by_name
272
+ from pygments.util import ClassNotFound
273
+
274
+ p = Paragraph(style=para_style)
275
+ if not code:
276
+ return p
277
+ try:
278
+ lexer = get_lexer_by_name(language) if language else TextLexer()
279
+ except ClassNotFound:
280
+ lexer = TextLexer()
281
+ try:
282
+ pyg_style = get_style_by_name(reg.pygments_style)
283
+ except Exception:
284
+ pyg_style = None
285
+ for ttype, text in lex(code, lexer):
286
+ if not text:
287
+ continue
288
+ color, bold, italic = style.fg, False, False
289
+ if pyg_style is not None:
290
+ tstyle = {}
291
+ t = ttype
292
+ while t is not None:
293
+ try:
294
+ tstyle = pyg_style.style_for_token(t)
295
+ break
296
+ except KeyError:
297
+ t = t.parent
298
+ if tstyle.get("color"):
299
+ color = f"#{tstyle['color']}"
300
+ bold, italic = bool(tstyle.get("bold")), bool(tstyle.get("italic"))
301
+ run = reg.run_style(color, style.font_family, style.font_size_px, bold, italic, mono=True)
302
+ p.append(Span(text, style=run))
303
+ return p
304
+
305
+
306
+ def _render_code(
307
+ elem: Elem, reg: StyleRegistry, add_file: AddFile, source_dir: Path | None,
308
+ global_tags: dict[str, ResolvedStyle],
309
+ ) -> list[Any]:
310
+ box = elem.box
311
+ if elem.svg or elem.pdf:
312
+ return _render_image_data(elem, box, add_file)
313
+ style = box.style
314
+ para_style = reg.paragraph_style(style, mono=True)
315
+ p = _highlighted_code_paragraph(elem.content or "", elem.language, style, reg, para_style)
316
+ gfx = reg.graphic_style(style, fill=True, vertical_align="top")
317
+ frame = Frame.text_frame(p, style=gfx, position=_pos(box), size=_size(box), anchor_type="page")
318
+ _apply_radius(frame, style)
319
+ return [frame]
320
+
321
+
322
+ def _render_list(
323
+ elem: Elem, reg: StyleRegistry, add_file: AddFile, source_dir: Path | None,
324
+ global_tags: dict[str, ResolvedStyle],
325
+ ) -> list[Any]:
326
+ box = elem.box
327
+ style = box.style
328
+ li_style = box.substyles.get("li", style)
329
+ para_style = reg.paragraph_style(li_style, margin_bottom_px=li_style.font_size_px * 0.4)
330
+ marker_fg = box.substyles.get("li::marker", li_style).fg
331
+ list_name = reg.list_style(marker_fg, li_style.font_family)
332
+ odf_list = OdfList(style=list_name)
333
+ inline_rows = elem.item_inlines or [[] for _ in elem.items]
334
+ for row in inline_rows:
335
+ p = (
336
+ _build_rich_paragraph(row, reg, li_style, global_tags, para_style)
337
+ if row else Paragraph(style=para_style)
338
+ )
339
+ odf_list.append_item(ListItem(p))
340
+ gfx = reg.graphic_style(style, fill=False)
341
+ frame = Frame.text_frame(odf_list, style=gfx, position=_pos(box), size=_size(box), anchor_type="page")
342
+ return [frame]
343
+
344
+
345
+ def _render_table(
346
+ elem: Elem, reg: StyleRegistry, add_file: AddFile, source_dir: Path | None,
347
+ global_tags: dict[str, ResolvedStyle],
348
+ ) -> list[Any]:
349
+ box = elem.box
350
+ style = box.style
351
+ sub = box.substyles
352
+ th_style = sub.get("th", style)
353
+ td_odd = sub.get("tr:odd", style)
354
+ td_even = sub.get("tr:even", style)
355
+
356
+ ncols = len(elem.headers)
357
+ nrows = len(elem.rows) + 1
358
+ t = Table(f"DecktileTable_{elem.id}", width=max(ncols, 1), height=max(nrows, 1))
359
+
360
+ # Without explicit column/row dims, Impress falls back to a fixed
361
+ # per-column/-row default (bigger than the frame's own measured size),
362
+ # rendering the table noticeably larger than the HTML -- size every
363
+ # column/row to split the frame's own box evenly.
364
+ if ncols:
365
+ col_style = reg.table_column_style(box.w / ncols)
366
+ for j in range(ncols):
367
+ t.set_column(j, Column(style=col_style))
368
+ if nrows:
369
+ row_style = reg.table_row_style(box.h / nrows)
370
+ for i in range(nrows):
371
+ t.set_row(i, Row(style=row_style))
372
+
373
+ header_cell = reg.table_cell_style(th_style.bg, th_style.border_color, th_style.border_width)
374
+ header_run = reg.run_style(th_style.fg, th_style.font_family, th_style.font_size_px, True, False)
375
+ for j, h in enumerate(elem.headers):
376
+ text = _strip_html(h)
377
+ t.set_value((j, 0), text)
378
+ cell = t.get_cell((j, 0), clone=False)
379
+ cell.set_attribute("table:style-name", header_cell)
380
+ p = cell.get_element("text:p")
381
+ if p is not None:
382
+ p.clear()
383
+ p.append(Span(text, style=header_run))
384
+
385
+ for i, row in enumerate(elem.rows):
386
+ row_fg_style = td_even if (i + 1) % 2 == 0 else td_odd
387
+ cell_name = reg.table_cell_style(row_fg_style.bg, row_fg_style.border_color, row_fg_style.border_width)
388
+ body_run = reg.run_style(
389
+ row_fg_style.fg, row_fg_style.font_family, row_fg_style.font_size_px, False, False,
390
+ )
391
+ for j, cell_text in enumerate(row):
392
+ text = _strip_html(cell_text)
393
+ t.set_value((j, i + 1), text)
394
+ cell = t.get_cell((j, i + 1), clone=False)
395
+ cell.set_attribute("table:style-name", cell_name)
396
+ p = cell.get_element("text:p")
397
+ if p is not None:
398
+ p.clear()
399
+ p.append(Span(text, style=body_run))
400
+
401
+ frame = Frame(
402
+ name=f"table_frame_{elem.id}",
403
+ position=_pos(box), size=_size(box), anchor_type="page",
404
+ )
405
+ frame.set_attribute("draw:fill", "none")
406
+ frame.append(t)
407
+ return [frame]
408
+
409
+
410
+ def _render_card(
411
+ elem: Elem, reg: StyleRegistry, add_file: AddFile, source_dir: Path | None,
412
+ global_tags: dict[str, ResolvedStyle],
413
+ ) -> list[Any]:
414
+ box = elem.box
415
+ style = box.style
416
+ header_style = box.substyles.get("h3", style)
417
+ body_style = box.substyles.get("p", style)
418
+
419
+ para_style = reg.paragraph_style(body_style)
420
+ content: list[Any] = []
421
+ if elem.header:
422
+ header_html = elem.header
423
+ hp = Paragraph(style=para_style)
424
+ m = _ICON_SVG_RE.match(header_html.lstrip())
425
+ if m:
426
+ # Leading {icon:...} on a card header: a glyph Span flows
427
+ # inline with the label text, matching the HTML's icon+space
428
+ # +label layout directly instead of an approximated gutter
429
+ # frame.
430
+ icon_span = _icon_glyph_span(
431
+ m.group(0), reg, global_tags, header_style.fg, header_style.font_size_px,
432
+ )
433
+ if icon_span is not None:
434
+ hp.append(icon_span)
435
+ hp.append(" ")
436
+ header_html = header_html[header_html.index(m.group(0)) + len(m.group(0)):]
437
+ head_name = reg.run_style(
438
+ header_style.fg, header_style.font_family, header_style.font_size_px,
439
+ header_style.font_weight == "bold", False,
440
+ )
441
+ hp.append(Span(_strip_html(header_html), style=head_name))
442
+ content.append(hp)
443
+ li_style = box.substyles.get("li", body_style)
444
+ li_para_style = reg.paragraph_style(li_style, margin_bottom_px=li_style.font_size_px * 0.4)
445
+ marker_fg = box.substyles.get("li::marker", li_style).fg
446
+ list_name = reg.list_style(marker_fg, li_style.font_family)
447
+ for line in elem.body:
448
+ items = _html_list_items(line)
449
+ if items is not None:
450
+ odf_list = OdfList(style=list_name)
451
+ for item in items:
452
+ odf_list.append_item(ListItem(_plain_paragraph(item, li_para_style)))
453
+ content.append(odf_list)
454
+ else:
455
+ content.append(_plain_paragraph(_strip_html(line), para_style))
456
+
457
+ gfx = reg.graphic_style(style, fill=True, vertical_align="top")
458
+ frame = Frame.text_frame(
459
+ content or [Paragraph(style=para_style)],
460
+ style=gfx, position=_pos(box), size=_size(box), anchor_type="page",
461
+ )
462
+ _apply_radius(frame, style)
463
+ return [frame, *_decoration_shapes(box, reg)]
464
+
465
+
466
+ def _render_grid(
467
+ elem: Elem, reg: StyleRegistry, add_file: AddFile, source_dir: Path | None,
468
+ global_tags: dict[str, ResolvedStyle],
469
+ ) -> list[Any]:
470
+ # Grid/row/col containers carry no visible paint of their own beyond
471
+ # any captured ::before/::after decoration (e.g. a timeline layout's
472
+ # spine) -- their children (already geometry-annotated with their own
473
+ # absolute boxes) render independently via render_elem's recursion.
474
+ return _decoration_shapes(elem.box, reg)
475
+
476
+
477
+ _render_row = _render_grid
478
+
479
+
480
+ def _render_speaker(
481
+ elem: Elem, reg: StyleRegistry, add_file: AddFile, source_dir: Path | None,
482
+ global_tags: dict[str, ResolvedStyle],
483
+ ) -> list[Any]:
484
+ box = elem.box
485
+ style = box.style
486
+ para_style = reg.paragraph_style(style)
487
+ text = elem.text or ""
488
+ links = [
489
+ elem.attrs[key].replace("https://", "").replace("mailto:", "")
490
+ for key in ("github", "twitter", "email", "linkedin", "website")
491
+ if elem.attrs.get(key)
492
+ ]
493
+ if links:
494
+ text = f"{text}\n{' · '.join(links)}" if text else " · ".join(links)
495
+ p = Paragraph(text, style=para_style)
496
+ gfx = reg.graphic_style(style, fill=False)
497
+ frame = Frame.text_frame(p, style=gfx, position=_pos(box), size=_size(box), anchor_type="page")
498
+ return [frame]
499
+
500
+
501
+ def _resolve_img_path(src: str, source_dir: Path | None) -> str | None:
502
+ if src.startswith(("http://", "https://", "data:")):
503
+ return None
504
+ path = str((source_dir / src).resolve()) if source_dir else src
505
+ return path if os.path.isfile(path) else None
506
+
507
+
508
+ def _normalize_svg_viewbox(svg: str) -> str:
509
+ """Normalize a negative viewBox origin to 0,0, expanding dimensions."""
510
+ import re
511
+
512
+ m = re.search(r'viewBox="([\d.-]+)\s+([\d.-]+)\s+([\d.]+)\s+([\d.]+)"', svg)
513
+ if not m:
514
+ return svg
515
+ x, y, w, h = float(m.group(1)), float(m.group(2)), float(m.group(3)), float(m.group(4))
516
+ if x >= 0 and y >= 0:
517
+ return svg
518
+ new_w, new_h = w + abs(x), h + abs(y)
519
+ return svg.replace(m.group(0), f'viewBox="0 0 {new_w:g} {new_h:g}"', 1)
520
+
521
+
522
+ def _svg_dims(svg: str, max_w_cm: float) -> tuple[float, float]:
523
+ import re
524
+
525
+ vb = re.search(r'viewBox="[\d.-]+\s+[\d.-]+\s+([\d.]+)\s+([\d.]+)"', svg)
526
+ if not vb:
527
+ return (max_w_cm, max_w_cm * 9 / 16)
528
+ vb_w, vb_h = float(vb.group(1)), float(vb.group(2))
529
+ if vb_w <= 0:
530
+ return (max_w_cm, max_w_cm * 9 / 16)
531
+ w = min(max_w_cm, vb_w * 0.0353)
532
+ return (w, w * (vb_h / vb_w))
533
+
534
+
535
+ _ICON_SVG_RE = re.compile(r"<svg\b[^>]*>.*?</svg>", re.DOTALL)
536
+ _ICON_NAME_RE = re.compile(r"\blucide-([a-z0-9-]+)-icon\b")
537
+ _ICON_CLASS_RE = re.compile(r'<svg\b[^>]*\bclass="([^"]*)"')
538
+
539
+
540
+ def _icon_accent_color(classes: str, global_tags: dict[str, ResolvedStyle], fallback_fg: str) -> str:
541
+ accent = next((c for c in classes.split() if c.startswith("accent-")), None)
542
+ style = global_tags.get(f"icon:{accent}") if accent else None
543
+ return style.fg if style else fallback_fg
544
+
545
+
546
+ def _icon_glyph_span(
547
+ svg: str, reg: StyleRegistry, global_tags: dict[str, ResolvedStyle],
548
+ fallback_fg: str, font_size_px: float,
549
+ ) -> Any | None:
550
+ """A lucide icon rendered as a colored font glyph (``lucide.ttf``,
551
+ embedded in the package) instead of an SVG image frame -- a real
552
+ text run, so it picks up a themed color like any other text and
553
+ scales with font-size instead of a fixed pixel box. Fixes the
554
+ longstanding "icon always renders black" gap: an inline <svg>'s
555
+ ``stroke="currentColor"`` has no separate, walkable weasyprint box
556
+ to resolve a themed color from (verified empirically), so the color
557
+ comes from the icon's own ``accent-*`` class instead (resolved once,
558
+ directly from the theme's ``:root`` custom properties -- see
559
+ geometry.py's ``_resolve_icon_accent_colors``)."""
560
+ from decktile.plugins.lucide_font import icon_glyph
561
+
562
+ name_m = _ICON_NAME_RE.search(svg)
563
+ if not name_m:
564
+ return None
565
+ glyph = icon_glyph(name_m.group(1))
566
+ if not glyph:
567
+ return None
568
+ cls_m = _ICON_CLASS_RE.search(svg)
569
+ color = _icon_accent_color(cls_m.group(1) if cls_m else "", global_tags, fallback_fg)
570
+ reg.ensure_lucide_font()
571
+ run = reg.run_style(color, "lucide", font_size_px, False, False)
572
+ return Span(glyph, style=run)
573
+
574
+
575
+ def _render_arrow(
576
+ elem: Elem, reg: StyleRegistry, add_file: AddFile, source_dir: Path | None,
577
+ global_tags: dict[str, ResolvedStyle],
578
+ ) -> list[Any]:
579
+ """``::: arrow`` fenced block: a single centered lucide icon (default a
580
+ plain arrow glyph), not text -- a centered glyph Span, falling back
581
+ to text if no icon markup is present."""
582
+ box = elem.box
583
+ span = _icon_glyph_span(elem.content or "", reg, global_tags, box.style.fg, box.style.font_size_px)
584
+ if span is None:
585
+ return _render_text_like(elem, reg, add_file, source_dir, global_tags)
586
+ centered = dataclasses.replace(box.style, text_align="center")
587
+ para_style = reg.paragraph_style(centered)
588
+ p = Paragraph(style=para_style)
589
+ p.append(span)
590
+ gfx = reg.graphic_style(centered, fill=False, vertical_align="middle")
591
+ frame = Frame.text_frame(p, style=gfx, position=_pos(box), size=_size(box), anchor_type="page")
592
+ return [frame]
593
+
594
+
595
+ def _render_image_data(elem: Elem, box: BoxGeometry, add_file: AddFile) -> list[Any]:
596
+ """Embed a mermaid/dot/seaborn diagram's rendered SVG (or mermaid's PDF
597
+ fallback) directly as an ODF image -- LibreOffice embeds SVG/PDF images
598
+ natively, no rasterization needed."""
599
+ import tempfile
600
+
601
+ size_cm = (box.w, box.h)
602
+ tmp_path = None
603
+ if elem.language == "mermaid" and elem.pdf:
604
+ with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as f:
605
+ f.write(elem.pdf)
606
+ tmp_path = f.name
607
+ elif elem.svg:
608
+ svg = _normalize_svg_viewbox(elem.svg)
609
+ size_cm = _svg_dims(svg, box.w)
610
+ with tempfile.NamedTemporaryFile(suffix=".svg", mode="w", delete=False) as f:
611
+ f.write(svg)
612
+ tmp_path = f.name
613
+ if tmp_path is None:
614
+ return [Frame.text_frame(
615
+ Paragraph(f"[{elem.language or 'diagram'}]"),
616
+ position=_pos(box), size=_size(box), anchor_type="page",
617
+ )]
618
+ uri = add_file(tmp_path)
619
+ os.unlink(tmp_path)
620
+ x, y = box.x + (box.w - size_cm[0]) / 2, box.y + (box.h - size_cm[1]) / 2
621
+ frame = Frame.image_frame(
622
+ image=uri,
623
+ size=(f"{size_cm[0]:.3f}cm", f"{size_cm[1]:.3f}cm"),
624
+ position=(f"{x:.3f}cm", f"{y:.3f}cm"),
625
+ anchor_type="page",
626
+ )
627
+ return [frame]
628
+
629
+
630
+ def _render_inline_images(
631
+ images: list, box: BoxGeometry, add_file: AddFile, source_dir: Path | None,
632
+ ) -> list[Any]:
633
+ """A paragraph whose entire content is one or more images, laid out
634
+ centered side by side within the paragraph's own box."""
635
+ resolved = [(img, _resolve_img_path(img.src, source_dir)) for img in images]
636
+ resolved = [(img, p) for img, p in resolved if p]
637
+ if not resolved:
638
+ return []
639
+ gap_cm = 0.3
640
+ slot_w = (box.w - gap_cm * (len(resolved) - 1)) / len(resolved)
641
+ frames = []
642
+ x = box.x
643
+ for img, path in resolved:
644
+ w_cm, h_cm = _image_natural_size(path)
645
+ if w_cm > slot_w:
646
+ h_cm *= slot_w / w_cm
647
+ w_cm = slot_w
648
+ if h_cm > box.h:
649
+ w_cm *= box.h / h_cm
650
+ h_cm = box.h
651
+ radius_cm = box.style.border_radius * CM_PER_PX
652
+ uri = add_file(round_image_corners(path, radius_cm, w_cm))
653
+ frame = Frame.image_frame(
654
+ image=uri, text=img.title or "",
655
+ size=(f"{w_cm:.3f}cm", f"{h_cm:.3f}cm"),
656
+ position=(f"{x + (slot_w - w_cm) / 2:.3f}cm", f"{box.y + (box.h - h_cm) / 2:.3f}cm"),
657
+ anchor_type="page",
658
+ )
659
+ if img.alt:
660
+ frame.svg_description = img.alt
661
+ frames.append(frame)
662
+ x += slot_w + gap_cm
663
+ return frames
664
+
665
+
666
+ def _image_natural_size(path: str) -> tuple[float, float]:
667
+ try:
668
+ from PIL import Image as PILImage
669
+
670
+ with PILImage.open(path) as im:
671
+ w_px, h_px = im.size
672
+ return (w_px * 2.54 / 96, h_px * 2.54 / 96)
673
+ except Exception:
674
+ return (16.0, 9.0)
675
+
676
+
677
+ def _render_image(
678
+ elem: Elem, reg: StyleRegistry, add_file: AddFile, source_dir: Path | None,
679
+ global_tags: dict[str, ResolvedStyle],
680
+ ) -> list[Any]:
681
+ box = elem.box
682
+ img = next((n for n in elem.inlines if isinstance(n, ASTImage)), None)
683
+ src = img.src if img else elem.src
684
+ path = _resolve_img_path(src, source_dir)
685
+ if not path:
686
+ return []
687
+ radius_cm = box.style.border_radius * CM_PER_PX
688
+ uri = add_file(round_image_corners(path, radius_cm, box.w))
689
+ frame = Frame.image_frame(image=uri, position=_pos(box), size=_size(box), anchor_type="page")
690
+ return [frame]
691
+
692
+
693
+ def _render_skip(
694
+ elem: Elem, reg: StyleRegistry, add_file: AddFile, source_dir: Path | None,
695
+ global_tags: dict[str, ResolvedStyle],
696
+ ) -> list[Any]:
697
+ """Elements with no ODF embedding (HTML5 <video>): render nothing, not a near-empty frame."""
698
+ return []
699
+
700
+
701
+ _RENDERERS: dict[str, Any] = {
702
+ "heading": _render_text_like,
703
+ "text": _render_text_like,
704
+ "kicker": _render_text_like,
705
+ "subtitle": _render_text_like,
706
+ "tiny": _render_text_like,
707
+ "arrow": _render_arrow,
708
+ "quote": _render_quote,
709
+ "notes": _render_notes,
710
+ "code": _render_code,
711
+ "list": _render_list,
712
+ "table": _render_table,
713
+ "card": _render_card,
714
+ "grid": _render_grid,
715
+ "row": _render_row,
716
+ "speaker": _render_speaker,
717
+ "side-image": _render_image,
718
+ "video": _render_skip,
719
+ }