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/odp.py ADDED
@@ -0,0 +1,380 @@
1
+ """ODP renderer: thin orchestrator over geometry + styles + shapes.
2
+
3
+ build_ir -> annotate_geometry -> one DrawPage per slide, with its
4
+ background built from that slide's own resolved section style (gradient/
5
+ solid/bitmap-plus-overlay) -- no separate dark-mode code path, the
6
+ resolved style already reflects whatever ``data-theme`` the section had.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import dataclasses
12
+ import os
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from odfdo import Document, DrawPage, Frame, Paragraph, PresentationNotes, RectangleShape
17
+ from odfdo.element import Element
18
+
19
+ from decktile.parser.ast import Document as ASTDocument
20
+ from decktile.render.ir import ResolvedStyle, build_ir
21
+ from decktile.render.odf.geometry import annotate_geometry
22
+ from decktile.render.odf.shapes import render_elem
23
+ from decktile.render.odf.styles import StyleRegistry, downscale_image
24
+
25
+
26
+ def render(
27
+ doc: ASTDocument,
28
+ output_path: Path,
29
+ base_css: str = "",
30
+ theme_css: str = "",
31
+ source_dir: Path | None = None,
32
+ ) -> None:
33
+ """Render Document AST to an ODP file.
34
+
35
+ Page size is always derived from the deck's own px dimensions
36
+ (``doc.meta.dimensions()``) via ``PX_TO_CM`` -- the same ratio
37
+ geometry.py positions every frame with. A fixed/independent page size
38
+ (the old default 28x15.75cm) would clip content: the geometry pass
39
+ lays out frames assuming the page equals the content's real size.
40
+ """
41
+ from decktile.render.ir import assemble_css
42
+ from decktile.render.odf.geometry import PX_TO_CM
43
+ from decktile.render.seaborn import set_palette
44
+
45
+ set_palette(doc.meta.seaborn_theme)
46
+ dims = doc.meta.dimensions()
47
+ page_width, page_height = dims[0] * PX_TO_CM, dims[1] * PX_TO_CM
48
+ css = assemble_css(
49
+ base_css, theme_css, dims, doc.meta.logo, doc.meta.watermark,
50
+ doc.meta.pygments_style or "default", doc.meta.logo_dark, doc.meta.background,
51
+ )
52
+
53
+ ir_slides = build_ir(doc, base_css, theme_css)
54
+ section_box, global_tags = annotate_geometry(ir_slides, doc, css, dims, source_dir)
55
+
56
+ odp = Document("presentation")
57
+ odp.body.clear()
58
+ reg = StyleRegistry(odp, doc.meta.pygments_style or "default")
59
+
60
+ def add_file(path: str) -> str:
61
+ return odp.add_file(path)
62
+
63
+ reg.ensure_page_layout(page_width, page_height)
64
+ # Fallback only: base.css always defines `section::before { background:
65
+ # var(--logo) ... }`, so the pseudo-element capture below is the normal
66
+ # path. This covers a theme that doesn't set --logo at all.
67
+ logo_path = _resolve_asset(doc.meta.logo, source_dir)
68
+ logo_dark_path = _resolve_asset(doc.meta.logo_dark, source_dir) or logo_path
69
+
70
+ for i, slide in enumerate(ir_slides):
71
+ sb = section_box.get(slide.id)
72
+ page = DrawPage(draw_id=slide.id, name=f"Slide {i + 1}")
73
+ shapes: list[Any] = []
74
+
75
+ if sb is not None:
76
+ master_name, is_new_master = reg.master_for(
77
+ sb.style, page_width, page_height, add_file, footer=sb.footer,
78
+ )
79
+ page.master_page = master_name
80
+ overlay = _overlay_shape(sb.style, reg, page_width, page_height)
81
+ if overlay is not None:
82
+ shapes.append(overlay)
83
+ # Logo/watermark/footer-text/page-number are the same for every
84
+ # slide sharing this master (same CSS rule, same fixed %-based
85
+ # position) -- append them to the master page itself, exactly
86
+ # once, instead of as a shape drawn on top of every single
87
+ # slide. The footer *bar* fill is already baked into the
88
+ # master's own background image (master_for); this adds the
89
+ # bar's text, the page-number field, and the logo/watermark
90
+ # images.
91
+ #
92
+ # Exception: a page whose background still needs a per-slide
93
+ # overlay rect (a true multi-stop/angled gradient, e.g.
94
+ # .layout-title, that can't be baked into the master --
95
+ # reg.overlay_needed) -- master content always paints *beneath*
96
+ # every per-slide shape in ODF, with no way to interleave by
97
+ # z-index, so chrome placed on the master would be painted
98
+ # over (dimmed/washed out) by that overlay on every single
99
+ # slide using it. Keep chrome per-slide there instead, ordered
100
+ # after the overlay so it paints on top of it, same as before
101
+ # this master-chrome change.
102
+ chrome_on_master = is_new_master and overlay is None
103
+ chrome_per_slide = overlay is not None
104
+ if chrome_on_master or chrome_per_slide:
105
+ max_digits = len(str(len(ir_slides)))
106
+ scope = "styles" if chrome_on_master else "content"
107
+ chrome = _chrome_shapes(sb, reg, add_file, max_digits, scope=scope)
108
+ if chrome_on_master:
109
+ mp = reg.master_page_element(master_name)
110
+ for shape in chrome:
111
+ mp.append(shape)
112
+ else:
113
+ shapes.extend(chrome)
114
+
115
+
116
+ # Fallback: a theme with no section::before CSS at all (sb.logo
117
+ # is None, not just resolved-to-nothing) -- doc.meta.logo/
118
+ # logo_dark directly, picked per slide.variant since it isn't
119
+ # tied to any master.
120
+ if (sb is None or sb.logo is None) and (logo_path or logo_dark_path):
121
+ slide_logo = logo_dark_path if slide.variant == "dark" else logo_path
122
+ if slide_logo:
123
+ shapes.append(_logo_frame(slide_logo, add_file, page_width, page_height))
124
+
125
+ for e in slide.elements:
126
+ shapes.extend(render_elem(e, reg, add_file, source_dir, global_tags))
127
+
128
+ # A bitmap-filled master page changes how LibreOffice resolves
129
+ # z-order for un-indexed shapes (confirmed empirically: an
130
+ # un-indexed overlay rect painted ABOVE a z-index=0 text frame --
131
+ # the same shapes with no master page stacked correctly in
132
+ # document order). Set an explicit, unique z-index per shape so
133
+ # paint order can never depend on that default.
134
+ for z, shape in enumerate(shapes):
135
+ shape.set_attribute("draw:z-index", str(z))
136
+ page.append(shape)
137
+
138
+ if slide.notes:
139
+ page.append(_notes_shape(slide.notes, reg))
140
+
141
+ odp.body.append(page)
142
+
143
+ odp.save(str(output_path), pretty=True)
144
+
145
+
146
+ def _resolve_asset(src: str, source_dir: Path | None) -> str | None:
147
+ if not src:
148
+ return None
149
+ path = str((source_dir / src).resolve()) if source_dir else src
150
+ return path if os.path.isfile(path) else None
151
+
152
+
153
+ def _chrome_shapes(
154
+ sb: Any, reg: StyleRegistry, add_file: Any, max_digits: int, *, scope: str,
155
+ ) -> list[Any]:
156
+ """footer-text, page-number field, logo, watermark -- the four shapes
157
+ shared across every slide using one background style, built once and
158
+ placed either on the master (``scope="styles"``) or, for an overlay-
159
+ needing background, per-slide (``scope="content"``) by the caller."""
160
+ shapes: list[Any] = []
161
+ footer_shape = sb.footer and _text_pseudo_frame(
162
+ sb.footer, reg, skip_fill=reg.footer_bar_baked(sb.style), scope=scope,
163
+ )
164
+ if footer_shape is not None:
165
+ shapes.append(footer_shape)
166
+ if sb.slide_num is not None:
167
+ # Measured from the first slide using this master, which may well
168
+ # be single-digit -- widen for the deck's actual max digit count
169
+ # (right-aligned, so the extra width grows leftward and the
170
+ # number stays flush with the original right edge), or a later
171
+ # double-digit page wraps vertically inside the too-narrow box
172
+ # instead of shrinking.
173
+ digit_w = sb.slide_num.h * 0.6
174
+ needed_w = max_digits * digit_w
175
+ num_pseudo = sb.slide_num
176
+ if needed_w > num_pseudo.w:
177
+ num_pseudo = dataclasses.replace(
178
+ num_pseudo,
179
+ x=num_pseudo.x + num_pseudo.w - needed_w,
180
+ w=needed_w,
181
+ style=dataclasses.replace(num_pseudo.style, text_align="right"),
182
+ )
183
+ page_num_shape = _text_pseudo_frame(num_pseudo, reg, page_field=True, scope=scope)
184
+ if page_num_shape is not None:
185
+ shapes.append(page_num_shape)
186
+ # Logo (section::before) / watermark (section::after): position,
187
+ # size, and file come directly from weasyprint's own resolved box for
188
+ # that pseudo-element -- exactly the HTML's CSS cascade, including
189
+ # any `--logo: var(--logo-dark)` swap on dark/title sections, with no
190
+ # separate Python-side light/dark selection. sb.logo being present
191
+ # but resolving to no image (`background-image: none`) is the
192
+ # cascade explicitly deciding no logo on this variant -- not a
193
+ # missing-CSS case, so no fallback there.
194
+ if sb.logo is not None:
195
+ logo_shape = _pseudo_image_frame(sb.logo, add_file)
196
+ if logo_shape is not None:
197
+ shapes.append(logo_shape)
198
+ watermark_shape = sb.watermark and _pseudo_image_frame(sb.watermark, add_file)
199
+ if watermark_shape is not None:
200
+ shapes.append(watermark_shape)
201
+ return shapes
202
+
203
+
204
+ def _pseudo_image_frame(pseudo: Any, add_file: Any) -> Frame | None:
205
+ """A section::before/::after box: exact position/size, and its own
206
+ background-image url is the exact file the HTML used (already
207
+ reflecting any CSS variable swap -- no separate Python-side selection).
208
+ CSS uses ``background-size: contain`` (preserve the logo's own
209
+ aspect, center within the box) -- Frame.image_frame stretches to the
210
+ declared size, so fit-and-center within the CSS box using the file's
211
+ real aspect ratio rather than distorting it to the box's aspect."""
212
+ url = pseudo.style.bg_image_url
213
+ if not url or not os.path.isfile(url):
214
+ return None
215
+ uri = add_file(downscale_image(url, pseudo.w, pseudo.h))
216
+ box_w, box_h = pseudo.w, pseudo.h
217
+ aspect = _image_aspect(url)
218
+ box_aspect = box_w / box_h if box_h else (aspect or 1.0)
219
+ if aspect and aspect > box_aspect:
220
+ w, h = box_w, box_w / aspect
221
+ elif aspect:
222
+ w, h = box_h * aspect, box_h
223
+ else:
224
+ w, h = box_w, box_h
225
+ x = pseudo.x + (box_w - w) / 2
226
+ y = pseudo.y + (box_h - h) / 2
227
+ return Frame.image_frame(
228
+ image=uri,
229
+ position=(f"{x:.3f}cm", f"{y:.3f}cm"),
230
+ size=(f"{max(w, 0.01):.3f}cm", f"{max(h, 0.01):.3f}cm"),
231
+ anchor_type="page",
232
+ )
233
+
234
+
235
+ def _text_pseudo_frame(
236
+ pseudo: Any, reg: StyleRegistry, *, skip_fill: bool = False, page_field: bool = False,
237
+ scope: str = "styles",
238
+ ) -> Frame | None:
239
+ """<footer>/.slide-num: a real DOM element injected by the HTML
240
+ template (shell.html), not an AST node -- geometry.py captures it by
241
+ tag/class against the enclosing section instead of by data-decktile-id.
242
+ Position/size/background/color are exactly weasyprint's own resolved
243
+ box for it (light-theme ``footer`` is a full-width bar via its own
244
+ `left:0;right:0`, dark/title themes leave it text-only -- no
245
+ Python-side special-casing, the CSS cascade already decided).
246
+ ``skip_fill``: the bar is already baked into the master's own bitmap
247
+ fill (``reg.footer_bar_baked``) -- painting it again here would
248
+ double it (and need its own, harder-to-get-right z-order against the
249
+ watermark/logo shapes that sit inside it).
250
+ ``page_field``: ``.slide-num``'s text is the slide's index at build
251
+ time, baked as a literal string -- inserting, deleting, or reordering
252
+ a slide afterward in Impress leaves every later page number wrong.
253
+ Use a live ``text:page-number`` field instead (``text:select-page=
254
+ "current"``, cached display value = the build-time number), which
255
+ Impress recomputes from actual page position regardless of later
256
+ edits.
257
+ ``scope``: this frame may land on the master page (``"styles"``) or,
258
+ for an overlay-needing background, on the slide itself (``"content"``)
259
+ -- Impress resolves text/paragraph styles from whichever XML part the
260
+ *shape* lives in, not the document as a whole, so the two placements
261
+ need oppositely-scoped styles or the font/color silently falls back
262
+ to a default serif/black.
263
+ """
264
+ if not pseudo.text:
265
+ return None
266
+ para_style = reg.paragraph_style(pseudo.style, scope=scope)
267
+ gfx = reg.graphic_style(
268
+ pseudo.style, fill=not skip_fill and pseudo.style.bg_alpha > 0,
269
+ vertical_align="middle", border=False,
270
+ )
271
+ if page_field:
272
+ p = Paragraph(style=para_style)
273
+ field = Element.from_tag("text:page-number")
274
+ field.set_attribute("text:select-page", "current")
275
+ field.text = pseudo.text
276
+ p.append(field)
277
+ else:
278
+ p = Paragraph(pseudo.text, style=para_style)
279
+ return Frame.text_frame(
280
+ p, style=gfx,
281
+ position=(f"{pseudo.x:.3f}cm", f"{pseudo.y:.3f}cm"),
282
+ size=(f"{max(pseudo.w, 0.01):.3f}cm", f"{max(pseudo.h, 0.01):.3f}cm"),
283
+ anchor_type="page",
284
+ )
285
+
286
+
287
+ def _overlay_shape(
288
+ style: ResolvedStyle, reg: StyleRegistry, page_w: float, page_h: float,
289
+ ) -> Any | None:
290
+ """Full-slide gradient/tint rect over a bitmap master background.
291
+
292
+ Needed only for a true multi-stop/angled gradient over a photo
293
+ (``.layout-title``'s directional fade) -- a uniform tint (every
294
+ other section's light/dark overlay) is baked directly into the
295
+ master's own bitmap fill by ``master_for``, one master per (image,
296
+ tint) switched via ``master_page`` instead of a per-slide alpha-
297
+ composited shape on top of one shared bitmap. ``reg.overlay_needed``
298
+ is the single source of truth (not re-derived here independently):
299
+ it reflects whether that baking actually succeeded, not just
300
+ whether it was attempted, so a PIL-missing environment can't end up
301
+ with neither the baked tint nor this overlay. A pure gradient/solid
302
+ fill (no bg_image_url) is already the master's own fill either way.
303
+ """
304
+ if not reg.overlay_needed(style):
305
+ return None
306
+ name, _opacity = reg.overlay_style(style)
307
+ return RectangleShape(
308
+ style=name, position=("0cm", "0cm"),
309
+ size=(f"{page_w:.3f}cm", f"{page_h:.3f}cm"), anchor_type="page",
310
+ )
311
+
312
+
313
+ def _image_aspect(path: str) -> float | None:
314
+ """width/height ratio of an image file. PIL has no SVG rasterizer at
315
+ all (raises on open) -- parse viewBox/width/height from the SVG text
316
+ directly rather than silently falling back to a guessed box (the old
317
+ fixed 3.0x1.3cm fallback compressed any wide logo, confirmed: a real
318
+ 1305x298 SVG logo rendered squeezed into a 3.0/1.3=2.31 aspect box
319
+ instead of its real 4.38 aspect)."""
320
+ if path.lower().endswith(".svg"):
321
+ import re
322
+
323
+ svg = Path(path).read_text(encoding="utf-8", errors="ignore")
324
+ m = re.search(r'viewBox="[\d.-]+\s+[\d.-]+\s+([\d.]+)\s+([\d.]+)"', svg)
325
+ if m:
326
+ w, h = float(m.group(1)), float(m.group(2))
327
+ return w / h if h else None
328
+ m = re.search(r'width="([\d.]+)[a-z]*"', svg)
329
+ m2 = re.search(r'height="([\d.]+)[a-z]*"', svg)
330
+ if m and m2 and float(m2.group(1)):
331
+ return float(m.group(1)) / float(m2.group(1))
332
+ return None
333
+ try:
334
+ from PIL import Image
335
+
336
+ with Image.open(path) as im:
337
+ w_px, h_px = im.size
338
+ return w_px / h_px if h_px else None
339
+ except Exception:
340
+ return None
341
+
342
+
343
+ def _logo_frame(logo_path: str, add_file: Any, page_w: float, page_h: float) -> Frame:
344
+ target_w, target_h = 3.0, 1.3
345
+ aspect = _image_aspect(logo_path)
346
+ if aspect:
347
+ target_h = target_w / aspect
348
+ uri = add_file(logo_path)
349
+ right_margin, top_margin = 1.0, 1.0
350
+ return Frame.image_frame(
351
+ image=uri, size=(f"{target_w:.2f}cm", f"{target_h:.2f}cm"),
352
+ position=(f"{page_w - target_w - right_margin:.2f}cm", f"{top_margin:.2f}cm"),
353
+ anchor_type="page",
354
+ )
355
+
356
+
357
+ def _notes_shape(text: str, reg: StyleRegistry) -> PresentationNotes:
358
+ """The notes page is a separate, independently-sized portrait page --
359
+ not derived from the slide's own landscape page dimensions. Geometry
360
+ matched against a LibreOffice-authored reference (python-pptx deck
361
+ with real notes, converted via soffice --convert-to odp): the
362
+ master's own "Blank-notes" placeholder sits at (2.1cm, 14.107cm)
363
+ sized 16.799cm x 13.364cm. Using the slide's own (wider, landscape)
364
+ page width/height here clipped every notes line off the right edge
365
+ of the portrait notes page."""
366
+ notes = PresentationNotes()
367
+ # One Paragraph per source line -- a plain Paragraph("a\nb") renders
368
+ # "a" and "b" run together (ODF text:p ignores embedded \n; it needs
369
+ # explicit text:line-break or paragraph breaks), which silently
370
+ # collapsed multi-line comments and bulleted notes onto one line.
371
+ paragraphs = [Paragraph(line) for line in text.split("\n")]
372
+ frame = Frame.text_frame(
373
+ paragraphs,
374
+ position=("2.1cm", "14.107cm"),
375
+ size=("16.799cm", "13.364cm"),
376
+ anchor_type="page", presentation_class="notes",
377
+ presentation_style=reg.notes_style(),
378
+ )
379
+ notes.append(frame)
380
+ return notes
decktile/render/pdf.py ADDED
@@ -0,0 +1,17 @@
1
+ """PDF renderer using weasyprint."""
2
+
3
+ from pathlib import Path
4
+
5
+
6
+ def render(html_path: Path, output_path: Path, optimize_images: bool = False) -> None:
7
+ from weasyprint import HTML # lazy: needs system Pango, only for PDF/ODP output
8
+
9
+ if optimize_images:
10
+ HTML(filename=str(html_path)).write_pdf(
11
+ target=str(output_path),
12
+ jpeg_quality=85,
13
+ optimize_images=True,
14
+ dpi=200,
15
+ )
16
+ else:
17
+ HTML(filename=str(html_path)).write_pdf(target=str(output_path))
decktile/render/pdg.py ADDED
@@ -0,0 +1,72 @@
1
+ """predictygraph renderer for decktile ```pdg blocks.
2
+
3
+ The slide theme's body font and tag colours are passed to predictygraph as a
4
+ stylesheet layered under the diagram's own css, so `node.green`, `node.cyan`,
5
+ `node.yellow` and `node.red` match decktile cards and anything in the block wins.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+
12
+ # ::: card {blueprint} (base.css): light lines and translucent fills on the
13
+ # card's blue grid; tags keep their meaning as brighter accents.
14
+ _BLUEPRINT_TAGS = {"default": ("rgba(143,211,255,0.08)", "#8fd3ff"),
15
+ "green": ("rgba(126,226,168,0.20)", "#7ee2a8"),
16
+ "red": ("rgba(255,138,138,0.20)", "#ff8a8a"),
17
+ "cyan": ("rgba(103,216,255,0.22)", "#67d8ff"),
18
+ "yellow": ("rgba(255,209,102,0.20)", "#ffd166")}
19
+ _BLUEPRINT_CSS = """\
20
+ graph { color: #eaf4ff; }
21
+ node { color: #eaf4ff; }
22
+ group { fill: none; stroke: rgba(143,211,255,0.55); stroke-dasharray: 4 3; color: #8fd3ff; }
23
+ edge { stroke: #8fd3ff; color: #eaf4ff; }"""
24
+
25
+
26
+ def theme_css(font_family: str = "", font_size: int = 0,
27
+ tag_colors: dict[str, tuple[str, str]] | None = None) -> str:
28
+ """Slide theme as a predictygraph stylesheet."""
29
+ rules = []
30
+ graph = []
31
+ if font_family:
32
+ graph.append(f"font-family: {font_family};")
33
+ if font_size:
34
+ graph.append(f"font-size: {int(font_size)}px;")
35
+ if graph:
36
+ rules.append("graph { " + " ".join(graph) + " }")
37
+ for tag, (fill, stroke) in (tag_colors or {}).items():
38
+ decl = []
39
+ if fill:
40
+ decl.append(f"fill: {fill};")
41
+ if stroke:
42
+ decl.append(f"stroke: {stroke};")
43
+ if not decl:
44
+ continue
45
+ sel = "node" if tag == "default" else f"node.{tag}"
46
+ rules.append(f"{sel} {{ " + " ".join(decl) + " }")
47
+ return "\n".join(rules)
48
+
49
+
50
+ def render_pdg_svg(content: str, font_family: str = "", font_size: int = 0,
51
+ tag_colors: dict[str, tuple[str, str]] | None = None,
52
+ blueprint: bool = False) -> str | None:
53
+ """Render a predictygraph diagram to SVG, or None if it fails to parse.
54
+
55
+ blueprint: palette for ``::: card {blueprint}`` (light lines, translucent fills).
56
+ """
57
+ import predictygraph
58
+
59
+ css = theme_css(font_family, font_size, _BLUEPRINT_TAGS if blueprint else tag_colors)
60
+ if blueprint:
61
+ css += "\n" + _BLUEPRINT_CSS
62
+ # Theme goes first so css inside the block overrides it.
63
+ text = "css {\n" + css + "\n}\n" + content
64
+ try:
65
+ svg = predictygraph.render(text, t=0)
66
+ except Exception:
67
+ return None
68
+ return _strip_xml_decl(svg)
69
+
70
+
71
+ def _strip_xml_decl(svg: str) -> str:
72
+ return re.sub(r"^\s*<\?xml[^>]*\?>\s*", "", svg)
@@ -0,0 +1,155 @@
1
+ """Seaborn code block execution for slide renderers.
2
+
3
+ Executes Python/seaborn code in-process from fenced code blocks and returns
4
+ SVG strings for both HTML inline and ODP embedding.
5
+
6
+ Only active when seaborn + matplotlib are installed (pdm install -G plot).
7
+ Falls back to plain code highlighting otherwise.
8
+
9
+ Theme support: the ``seaborn_theme`` frontmatter field accepts either a
10
+ seaborn palette name ("Paired", "deep", etc.) or a decktile theme name that
11
+ matches a CSS file in ``decktile/themes/<name>.css``. CSS variables are
12
+ parsed and mapped to matplotlib rcParams -- no hardcoded color duplication.
13
+ Style modules in ``decktile.seaborn_styles.<name>`` take precedence over CSS
14
+ and can set ``seaborn_palette`` to override the default ("Paired").
15
+ """
16
+
17
+ import importlib
18
+ import io
19
+ import re
20
+ from pathlib import Path
21
+
22
+ _DEFAULT_FIGSIZE = (6.4, 4.8)
23
+ _DPI = 300
24
+ _DEFAULT_PALETTE = "Paired"
25
+ _THEME_DIR = Path(__file__).parent.parent / "themes"
26
+ _palette: str | None = None
27
+ _style: dict | None = None
28
+
29
+ # CSS variable → matplotlib rcParam(s)
30
+ _CSS_TO_RC = {
31
+ "--color-card-bg": "axes.facecolor",
32
+ "--color-accent-primary": ("axes.edgecolor", "axes.titlecolor"),
33
+ "--color-accent-secondary": "patch.edgecolor",
34
+ "--color-foreground": ("axes.labelcolor", "text.color"),
35
+ "--color-background": "figure.facecolor",
36
+ "--color-dimmed": ("xtick.color", "ytick.color"),
37
+ }
38
+
39
+
40
+ def _css_theme_rcparams(name: str) -> dict | None:
41
+ """Parse a decktile CSS theme file into matplotlib rcParams. None if not found."""
42
+ css_path = _THEME_DIR / f"{name}.css"
43
+ if not css_path.is_file():
44
+ return None
45
+ raw = css_path.read_text(encoding="utf-8")
46
+
47
+ # Extract only :root block (light theme), not [data-theme="dark"]
48
+ root_match = re.search(r":root\s*\{([^}]*)\}", raw, re.DOTALL)
49
+ root_css = root_match.group(1) if root_match else raw
50
+
51
+ vars_: dict[str, str] = {}
52
+ for m in re.finditer(r"(--[\w-]+)\s*:\s*([^;]+);", root_css):
53
+ vars_[m.group(1)] = m.group(2).strip()
54
+
55
+ font_match = re.search(r"section\s*\{([^}]*)\}", raw, re.DOTALL)
56
+ font_css = font_match.group(1) if font_match else ""
57
+ font_family = re.search(r"font-family:\s*([^;}]+)", font_css)
58
+ font_stack = font_family.group(1).strip() if font_family else "sans-serif"
59
+ fonts = [f.strip().strip('"') for f in font_stack.split(",")]
60
+
61
+ style: dict = {
62
+ "font.family": "sans-serif",
63
+ "font.sans-serif": fonts,
64
+ "patch.force_edgecolor": True,
65
+ }
66
+ for var, rc in _CSS_TO_RC.items():
67
+ val = vars_.get(var)
68
+ if val is None:
69
+ continue
70
+ if isinstance(rc, tuple):
71
+ for r in rc:
72
+ style[r] = val
73
+ else:
74
+ style[rc] = val
75
+ return style
76
+
77
+
78
+ def set_theme(name: str) -> None:
79
+ """Apply a seaborn palette or decktile style by name.
80
+
81
+ Resolution order: decktile style module > CSS theme file > palette name.
82
+ """
83
+ global _palette, _style
84
+ if not name:
85
+ name = _DEFAULT_PALETTE
86
+ try:
87
+ # 1) style module (for custom overrides CSS can't express)
88
+ mod = importlib.import_module(f"decktile.seaborn_styles.{name}")
89
+ style = dict(getattr(mod, "STYLE", {}))
90
+ if style:
91
+ import matplotlib
92
+ matplotlib.use("Agg")
93
+ import matplotlib.pyplot as plt
94
+ import seaborn as sns
95
+ palette = style.pop("seaborn_palette", _DEFAULT_PALETTE)
96
+ sns.set_theme(style="darkgrid", palette=palette)
97
+ plt.rcParams.update(style)
98
+ _style = style
99
+ _palette = palette
100
+ return
101
+ except ImportError:
102
+ pass
103
+
104
+ # 2) CSS theme file (single source of truth for brand colors)
105
+ css_style = _css_theme_rcparams(name)
106
+ if css_style:
107
+ import matplotlib
108
+ matplotlib.use("Agg")
109
+ import matplotlib.pyplot as plt
110
+ import seaborn as sns
111
+ palette = css_style.pop("seaborn_palette", _DEFAULT_PALETTE)
112
+ sns.set_theme(style="darkgrid", palette=palette)
113
+ plt.rcParams.update(css_style)
114
+ _style = css_style
115
+ _palette = None
116
+ return
117
+
118
+ # 3) seaborn palette name
119
+ _palette = name
120
+ _style = None
121
+
122
+
123
+ # backward compat alias
124
+ set_palette = set_theme
125
+
126
+
127
+ def render_seaborn_svg(content: str) -> str | None:
128
+ """Execute seaborn code in-process, return SVG string. None on failure."""
129
+ try:
130
+ import matplotlib
131
+ matplotlib.use("Agg")
132
+ import matplotlib.pyplot as plt
133
+ import seaborn as sns
134
+ import pandas as pd
135
+ import numpy as np
136
+ except ImportError:
137
+ return None
138
+
139
+ try:
140
+ if _style is not None:
141
+ plt.rcParams.update(_style)
142
+ sns.set_palette(_palette or _DEFAULT_PALETTE)
143
+ plt.figure(figsize=_DEFAULT_FIGSIZE)
144
+
145
+ exec(content, {"sns": sns, "plt": plt, "pd": pd, "np": np})
146
+
147
+ buf = io.BytesIO()
148
+ plt.savefig(buf, format="svg", dpi=_DPI, bbox_inches="tight")
149
+ plt.close("all")
150
+
151
+ buf.seek(0)
152
+ return buf.getvalue().decode("utf-8")
153
+ except Exception:
154
+ plt.close("all")
155
+ return None