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
|
@@ -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 (`>`), 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
|
+
}
|