decktile 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- decktile/__init__.py +5 -0
- decktile/__main__.py +3 -0
- decktile/assets/lucide/VENDORED.md +21 -0
- decktile/assets/lucide/lucide.css +2193 -0
- decktile/assets/lucide/lucide.ttf +3 -0
- decktile/cli.py +269 -0
- decktile/parser/__init__.py +0 -0
- decktile/parser/ast.py +191 -0
- decktile/parser/markdown.py +289 -0
- decktile/plugins/cards.py +25 -0
- decktile/plugins/directives.py +56 -0
- decktile/plugins/fenced.py +228 -0
- decktile/plugins/layouts.py +69 -0
- decktile/plugins/lucide.py +65 -0
- decktile/plugins/lucide_font.py +26 -0
- decktile/render/__init__.py +0 -0
- decktile/render/html.py +287 -0
- decktile/render/ir.py +536 -0
- decktile/render/odf/__init__.py +4 -0
- decktile/render/odf/geometry.py +510 -0
- decktile/render/odf/shapes.py +719 -0
- decktile/render/odf/styles.py +828 -0
- decktile/render/odp.py +380 -0
- decktile/render/pdf.py +17 -0
- decktile/render/pdg.py +72 -0
- decktile/render/seaborn.py +155 -0
- decktile/render/templates/base.css +741 -0
- decktile/render/templates/decktile.js +371 -0
- decktile/render/templates/shell.html +106 -0
- decktile/seaborn_styles/__init__.py +5 -0
- decktile/seaborn_styles/ossummit_europe.py +30 -0
- decktile/theme/__init__.py +0 -0
- decktile/theme/colors.py +53 -0
- decktile/theme/loader.py +22 -0
- decktile/theme/parser.py +264 -0
- decktile/themes/default.css +146 -0
- decktile/themes/kcd_vietnam.css +159 -0
- decktile/themes/kubecon_china.css +213 -0
- decktile/themes/kubecon_japan.css +156 -0
- decktile/themes/ossummit_europe.css +196 -0
- decktile/themes/ossummit_korea.css +196 -0
- decktile-0.2.0.dist-info/METADATA +625 -0
- decktile-0.2.0.dist-info/RECORD +46 -0
- decktile-0.2.0.dist-info/WHEEL +4 -0
- decktile-0.2.0.dist-info/entry_points.txt +6 -0
- decktile-0.2.0.dist-info/licenses/LICENSE +674 -0
decktile/render/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
|