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,65 @@
|
|
|
1
|
+
"""markdown-it inline rule for lucide icons: {icon:star stroke=#64d039}"""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from markdown_it.rules_inline import StateInline
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
_ICON_RE = re.compile(r'\{icon:(\S+)\s*([^}]*)\}')
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def render_icon(name: str, attrs: dict[str, str] | None = None) -> str:
|
|
11
|
+
"""Render a lucide icon to inline SVG. Shared by plugin and fenced preprocessor."""
|
|
12
|
+
if not name:
|
|
13
|
+
return ""
|
|
14
|
+
try:
|
|
15
|
+
from lucide import lucide_icon
|
|
16
|
+
kwargs = dict(attrs or {})
|
|
17
|
+
if "size" in kwargs:
|
|
18
|
+
v = kwargs.pop("size")
|
|
19
|
+
kwargs["height"] = kwargs["width"] = v
|
|
20
|
+
has_explicit_size = "height" in kwargs or "width" in kwargs
|
|
21
|
+
if not has_explicit_size:
|
|
22
|
+
kwargs["height"] = "1em"
|
|
23
|
+
svg = lucide_icon(name, **kwargs)
|
|
24
|
+
if not has_explicit_size:
|
|
25
|
+
svg = svg.replace('<svg', '<svg style="height:1em;width:auto;vertical-align:middle"', 1)
|
|
26
|
+
return svg
|
|
27
|
+
except Exception:
|
|
28
|
+
return ""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _parse_opts(opts: str) -> dict[str, str]:
|
|
32
|
+
attrs = {}
|
|
33
|
+
for a in re.finditer(r'(\w+)="([^"]*)"', opts):
|
|
34
|
+
attrs[a.group(1)] = a.group(2)
|
|
35
|
+
for a in re.finditer(r'(\w+)=(\S+)', opts):
|
|
36
|
+
if a.group(1) not in attrs:
|
|
37
|
+
attrs[a.group(1)] = a.group(2)
|
|
38
|
+
return attrs
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def lucide_icon_inline(state: StateInline, silent: bool = False) -> bool:
|
|
42
|
+
m = _ICON_RE.match(state.src[state.pos:])
|
|
43
|
+
if not m:
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
if not silent:
|
|
47
|
+
name = m.group(1)
|
|
48
|
+
attrs = _parse_opts(m.group(2))
|
|
49
|
+
token = state.push("lucide_icon", "svg", 0)
|
|
50
|
+
token.attrSet("name", name)
|
|
51
|
+
for k, v in attrs.items():
|
|
52
|
+
token.attrSet(k, v)
|
|
53
|
+
|
|
54
|
+
state.pos += len(m.group(0))
|
|
55
|
+
return True
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _render_lucide_token(self, tokens, idx, options, env) -> str:
|
|
59
|
+
attrs = dict(tokens[idx].attrs or {})
|
|
60
|
+
return render_icon(attrs.pop("name", ""), attrs)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def lucide_plugin(md):
|
|
64
|
+
md.inline.ruler.before("image", "lucide_icon", lucide_icon_inline)
|
|
65
|
+
md.add_render_rule("lucide_icon", _render_lucide_token)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Lucide icon font: name -> glyph codepoint, parsed from the vendored
|
|
2
|
+
lucide.css codepoint map (src/decktile/assets/lucide/VENDORED.md).
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
from functools import lru_cache
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
FONT_DIR = Path(__file__).parent.parent / "assets" / "lucide"
|
|
12
|
+
FONT_PATH = FONT_DIR / "lucide.ttf"
|
|
13
|
+
|
|
14
|
+
_CODEPOINT_RE = re.compile(r'\.icon-([a-z0-9-]+)::before\s*\{\s*content:\s*"\\e([0-9a-f]+)"\s*;')
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@lru_cache(maxsize=1)
|
|
18
|
+
def _codepoints() -> dict[str, str]:
|
|
19
|
+
css = (FONT_DIR / "lucide.css").read_text(encoding="utf-8")
|
|
20
|
+
return {name: chr(int("e" + hexcode, 16)) for name, hexcode in _CODEPOINT_RE.findall(css)}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def icon_glyph(name: str) -> str | None:
|
|
24
|
+
"""The single glyph character for a lucide icon name, or None if the
|
|
25
|
+
vendored font doesn't have it."""
|
|
26
|
+
return _codepoints().get(name)
|
|
File without changes
|
decktile/render/html.py
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""HTML renderer for decktile."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
import tempfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from jinja2 import Environment, FileSystemLoader
|
|
8
|
+
from pygments import highlight
|
|
9
|
+
from pygments.lexers import get_lexer_by_name, TextLexer
|
|
10
|
+
from pygments.formatters import HtmlFormatter
|
|
11
|
+
from pygments.util import ClassNotFound
|
|
12
|
+
|
|
13
|
+
from decktile.parser.ast import Document
|
|
14
|
+
from decktile.render.ir import build_ir, SlideIR, Elem
|
|
15
|
+
from decktile.plugins.layouts import KNOWN_LAYOUTS
|
|
16
|
+
|
|
17
|
+
_pygments_style = "default"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
_BRAND_ICON_SVGS = {
|
|
21
|
+
"linkedin": (
|
|
22
|
+
'<svg class="speaker-icon" xmlns="http://www.w3.org/2000/svg" '
|
|
23
|
+
'viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" '
|
|
24
|
+
'stroke-linecap="round" stroke-linejoin="round" '
|
|
25
|
+
'style="height:1em;width:auto;vertical-align:middle">'
|
|
26
|
+
'<path d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z"/>'
|
|
27
|
+
'<rect width="4" height="12" x="2" y="9"/>'
|
|
28
|
+
'<circle cx="4" cy="4" r="2"/></svg>'
|
|
29
|
+
),
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
TEMPLATE_DIR = Path(__file__).parent / "templates"
|
|
33
|
+
THEME_DIR = Path(__file__).parent.parent / "themes"
|
|
34
|
+
_env = Environment(loader=FileSystemLoader(str(TEMPLATE_DIR)), autoescape=False)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def default_theme() -> str:
|
|
38
|
+
p = THEME_DIR / "default.css"
|
|
39
|
+
return p.read_text(encoding="utf-8") if p.exists() else ""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def load_theme(name: str) -> str:
|
|
43
|
+
"""Load a named theme CSS file. Falls back to default if not found."""
|
|
44
|
+
p = THEME_DIR / f"{name}.css"
|
|
45
|
+
return p.read_text(encoding="utf-8") if p.exists() else default_theme()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def base_css() -> str:
|
|
49
|
+
p = TEMPLATE_DIR / "base.css"
|
|
50
|
+
return p.read_text(encoding="utf-8") if p.exists() else ""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def render(doc: Document, theme_css: str, assembled_css: str, dims: tuple[int, int], logo: str = "") -> str:
|
|
54
|
+
from decktile.render.seaborn import set_palette
|
|
55
|
+
|
|
56
|
+
global _pygments_style
|
|
57
|
+
_pygments_style = doc.meta.pygments_style or "default"
|
|
58
|
+
set_palette(doc.meta.seaborn_theme)
|
|
59
|
+
|
|
60
|
+
ir_slides = build_ir(doc, base_css(), theme_css)
|
|
61
|
+
return render_from_ir(ir_slides, doc, assembled_css, dims)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def render_from_ir(ir_slides: list[SlideIR], doc: Document, assembled_css: str,
|
|
65
|
+
dims: tuple[int, int]) -> str:
|
|
66
|
+
"""Render already-built IR slides to the full HTML page.
|
|
67
|
+
|
|
68
|
+
Split out from render() so the ODP geometry pass can render the exact
|
|
69
|
+
same IR (same data-decktile-id values) it will later annotate.
|
|
70
|
+
"""
|
|
71
|
+
slides = []
|
|
72
|
+
for i, slide in enumerate(ir_slides):
|
|
73
|
+
children = _render_slide(slide)
|
|
74
|
+
slides.append({
|
|
75
|
+
"id": slide.id, "num": i + 1, "layout": slide.layout, "children": children,
|
|
76
|
+
"notes": slide.notes, "footer": doc.meta.footer or "", "paginate": doc.meta.paginate or False,
|
|
77
|
+
"variant": slide.variant or doc.meta.theme_variant,
|
|
78
|
+
"transition": slide.transition or doc.meta.transition or "",
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
return _env.get_template("shell.html").render(
|
|
82
|
+
title=doc.meta.title or "Presentation",
|
|
83
|
+
slide_w=dims[0], slide_h=dims[1],
|
|
84
|
+
css=assembled_css, slides=slides,
|
|
85
|
+
variant=doc.meta.theme_variant or "light",
|
|
86
|
+
autoplay=doc.meta.autoplay,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _render_slide(slide: SlideIR) -> str:
|
|
91
|
+
elems = list(slide.elements)
|
|
92
|
+
heading_html = ""
|
|
93
|
+
if elems and elems[0].kind == "heading":
|
|
94
|
+
heading_html = _render_elem(elems[0])
|
|
95
|
+
elems = elems[1:]
|
|
96
|
+
if elems and elems[0].kind == "subtitle":
|
|
97
|
+
heading_html += "\n" + _render_elem(elems[0])
|
|
98
|
+
elems = elems[1:]
|
|
99
|
+
|
|
100
|
+
side_html = ""
|
|
101
|
+
for e in list(elems):
|
|
102
|
+
if e.kind == "side-image":
|
|
103
|
+
side_html = f'<div class="side-image" data-decktile-id="{e.id}"><img src="{_escape(e.src)}" alt=""></div>'
|
|
104
|
+
elems.remove(e)
|
|
105
|
+
|
|
106
|
+
body_html = "\n".join(filter(None, (_render_elem(e) for e in elems)))
|
|
107
|
+
if body_html.strip():
|
|
108
|
+
heading_html += '\n<div class="slide-body">\n' + body_html + '\n</div>'
|
|
109
|
+
if side_html:
|
|
110
|
+
heading_html += side_html
|
|
111
|
+
return heading_html or body_html
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _render_elem(e: Elem) -> str:
|
|
115
|
+
sid = f' data-decktile-id="{e.id}"'
|
|
116
|
+
if e.kind == "heading":
|
|
117
|
+
tag = f"h{e.level}"
|
|
118
|
+
return f"<{tag}{sid}>{e.content}</{tag}>"
|
|
119
|
+
elif e.kind == "text":
|
|
120
|
+
return f"<p{sid}>{e.content}</p>" if e.content else ""
|
|
121
|
+
elif e.kind == "quote":
|
|
122
|
+
return f'<div class="quote"{sid}>{e.content}</div>' if e.content else ""
|
|
123
|
+
elif e.kind == "code":
|
|
124
|
+
if e.language == "mermaid":
|
|
125
|
+
return _render_mermaid(e.content, e.svg, sid)
|
|
126
|
+
if e.language == "seaborn":
|
|
127
|
+
return _render_seaborn_html(e.content, e.svg, sid)
|
|
128
|
+
if e.language == "pdg":
|
|
129
|
+
return _render_pdg_html(e.content, e.svg, sid)
|
|
130
|
+
return _highlight_code(e.content, e.language, sid)
|
|
131
|
+
elif e.kind == "list":
|
|
132
|
+
s = f"<ul{sid}>\n"
|
|
133
|
+
for item in e.items:
|
|
134
|
+
s += f"<li>{item}</li>\n"
|
|
135
|
+
s += "</ul>"
|
|
136
|
+
return s
|
|
137
|
+
elif e.kind == "table":
|
|
138
|
+
# cells are inline HTML rendered by the parser (markdown-it), like
|
|
139
|
+
# card text -- escaping them again showed '>' as '&gt;'
|
|
140
|
+
s = f"<table{sid}>\n<thead>\n<tr>"
|
|
141
|
+
for h in e.headers:
|
|
142
|
+
s += f"<th>{h}</th>"
|
|
143
|
+
s += "</tr>\n</thead>\n<tbody>\n"
|
|
144
|
+
for row in e.rows:
|
|
145
|
+
s += "<tr>"
|
|
146
|
+
for cell in row:
|
|
147
|
+
s += f"<td>{cell}</td>"
|
|
148
|
+
s += "</tr>\n"
|
|
149
|
+
s += "</tbody>\n</table>"
|
|
150
|
+
return s
|
|
151
|
+
elif e.kind == "grid":
|
|
152
|
+
if e.layout:
|
|
153
|
+
cls = f"{e.layout}"
|
|
154
|
+
if "compare" in cls:
|
|
155
|
+
# children: col-left, arrow, col-right
|
|
156
|
+
parts = "\n".join(filter(None, (_render_elem(c) for c in e.children)))
|
|
157
|
+
return f'<div class="{cls}"{sid}>\n{parts}\n</div>'
|
|
158
|
+
children = "\n".join(filter(None, (_render_elem(c) for c in e.children)))
|
|
159
|
+
return f'<div class="{cls}"{sid}>\n{children}\n</div>'
|
|
160
|
+
cols = e.cols or len(e.children) or 2
|
|
161
|
+
style = f"grid-template-columns: repeat({cols}, 1fr); gap: 16px;"
|
|
162
|
+
cls = "grid"
|
|
163
|
+
if e.class_:
|
|
164
|
+
cls += f" {e.class_}"
|
|
165
|
+
children = "\n".join(filter(None, (_render_elem(c) for c in e.children)))
|
|
166
|
+
return f'<div class="{cls}" style="{style}"{sid}>\n{children}\n</div>'
|
|
167
|
+
elif e.kind == "card":
|
|
168
|
+
cls = "card"
|
|
169
|
+
if e.tag:
|
|
170
|
+
cls += f" tag-{e.tag}"
|
|
171
|
+
if e.class_:
|
|
172
|
+
cls += f" {e.class_}"
|
|
173
|
+
s = f'<div class="{cls}"{sid}>\n'
|
|
174
|
+
if e.attrs.get("label"):
|
|
175
|
+
s += f'<span class="timeline-label">{_escape(e.attrs["label"])}</span>\n'
|
|
176
|
+
# header/body are inline HTML from fenced._expand_markdown (already
|
|
177
|
+
# escaped by markdown-it): escaping again turned '>80%' into '&gt;80%'
|
|
178
|
+
if e.header:
|
|
179
|
+
s += f"<h3>{e.header}</h3>\n"
|
|
180
|
+
for line in e.body:
|
|
181
|
+
if line.startswith("<ul") or line.startswith("<ol"):
|
|
182
|
+
s += line + "\n"
|
|
183
|
+
else:
|
|
184
|
+
s += f"<p>{line}</p>\n"
|
|
185
|
+
for child in e.children:
|
|
186
|
+
s += _render_elem(child) + "\n"
|
|
187
|
+
s += "</div>"
|
|
188
|
+
return s
|
|
189
|
+
elif e.kind == "arrow":
|
|
190
|
+
return f'<div class="card-arrow"{sid}>{e.content}</div>'
|
|
191
|
+
elif e.kind == "row":
|
|
192
|
+
children = "\n".join(filter(None, (_render_elem(c) for c in e.children)))
|
|
193
|
+
return f'<div class="row"{sid}>\n{children}\n</div>'
|
|
194
|
+
elif e.kind == "notes":
|
|
195
|
+
cls = "notes"
|
|
196
|
+
if e.tag:
|
|
197
|
+
cls += f" tag-{e.tag}"
|
|
198
|
+
return f'<div class="{cls}"{sid}>{e.content}</div>'
|
|
199
|
+
elif e.kind == "speaker":
|
|
200
|
+
from decktile.plugins.lucide import render_icon
|
|
201
|
+
name = e.attrs.get("name", e.content)
|
|
202
|
+
role = e.attrs.get("role", "")
|
|
203
|
+
text = f"{_escape(name)}<br><span class=\"role\">{_escape(role)}</span>" if role else _escape(name)
|
|
204
|
+
links = []
|
|
205
|
+
contact_icons = {"github": "git-fork", "twitter": "bird", "email": "mail", "website": "globe"}
|
|
206
|
+
for key in ("github", "twitter", "email", "linkedin", "website"):
|
|
207
|
+
val = e.attrs.get(key, "")
|
|
208
|
+
if val:
|
|
209
|
+
icon = contact_icons.get(key, "link")
|
|
210
|
+
href = val if "://" in val else f"https://{val}" if key != "email" else f"mailto:{val}"
|
|
211
|
+
label = val.replace("https://", "").replace("mailto:", "")
|
|
212
|
+
svg = _BRAND_ICON_SVGS.get(key) or render_icon(icon, {"cls": "speaker-icon"})
|
|
213
|
+
links.append(f'<a href="{_escape(href)}" target="_blank">{svg} {_escape(label)}</a>')
|
|
214
|
+
if links:
|
|
215
|
+
text += '<br><span class="speaker-links">' + "".join(links) + "</span>"
|
|
216
|
+
label = e.attrs.get("contributor")
|
|
217
|
+
if label:
|
|
218
|
+
return f'<div class="speaker contributor"{sid}><span class="contributor-label">{_escape(label)}</span><br>{text}</div>'
|
|
219
|
+
return f'<div class="speaker"{sid}>{text}</div>'
|
|
220
|
+
elif e.kind in ("kicker", "subtitle", "tiny"):
|
|
221
|
+
return f'<p class="{e.kind}"{sid}>{e.content}</p>'
|
|
222
|
+
elif e.kind == "video":
|
|
223
|
+
poster = _video_poster(e.src)
|
|
224
|
+
return (f'<figure class="video"{sid}>'
|
|
225
|
+
f'<video controls preload="metadata" src="{_escape(e.src)}" '
|
|
226
|
+
f'poster="{_escape(poster)}"></video>'
|
|
227
|
+
f'<img class="video-print" src="{_escape(poster)}" alt="">'
|
|
228
|
+
f'</figure>')
|
|
229
|
+
elif e.kind == "row-marker":
|
|
230
|
+
return ""
|
|
231
|
+
return ""
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _highlight_code(content: str, language: str, sid: str = "") -> str:
|
|
235
|
+
try:
|
|
236
|
+
lexer = get_lexer_by_name(language, stripall=True) if language else TextLexer()
|
|
237
|
+
except ClassNotFound:
|
|
238
|
+
lexer = TextLexer()
|
|
239
|
+
formatter = HtmlFormatter(nowrap=True, style=_pygments_style)
|
|
240
|
+
highlighted = highlight(content, lexer, formatter)
|
|
241
|
+
cls = f' class="highlight language-{language}"' if language else ' class="highlight"'
|
|
242
|
+
return f'<pre{cls}{sid}><code>{highlighted}</code></pre>'
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _pygments_css(style: str = "default") -> str:
|
|
246
|
+
formatter = HtmlFormatter(style=style)
|
|
247
|
+
css = formatter.get_style_defs('.slide .highlight')
|
|
248
|
+
return f"\n/* ---- pygments ---- */\n{css}\n"
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _render_seaborn_html(content: str, svg: str = "", sid: str = "") -> str:
|
|
252
|
+
if svg:
|
|
253
|
+
return f'<div class="seaborn-plot"{sid}>\n{svg}\n</div>'
|
|
254
|
+
return f'<pre class="seaborn-fallback"{sid}><code>{_escape(content)}</code></pre>'
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _render_mermaid(content: str, svg: str = "", sid: str = "") -> str:
|
|
258
|
+
if svg:
|
|
259
|
+
return f'<div class="mermaid"{sid}>\n{svg}\n</div>'
|
|
260
|
+
return f'<pre class="mermaid-fallback"{sid}><code>{_escape(content)}</code></pre>'
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _render_pdg_html(content: str, svg: str = "", sid: str = "") -> str:
|
|
264
|
+
if svg:
|
|
265
|
+
return f'<div class="pdg-plot"{sid}>\n{svg}\n</div>'
|
|
266
|
+
return f'<pre class="pdg-fallback"{sid}><code>{_escape(content)}</code></pre>'
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _normalize_viewbox(svg: str) -> str:
|
|
270
|
+
import re
|
|
271
|
+
m = re.search(r'viewBox="([\d.-]+)\s+([\d.-]+)\s+([\d.]+)\s+([\d.]+)"', svg)
|
|
272
|
+
if not m:
|
|
273
|
+
return svg
|
|
274
|
+
x, y, w, h = float(m.group(1)), float(m.group(2)), float(m.group(3)), float(m.group(4))
|
|
275
|
+
if x >= 0 and y >= 0:
|
|
276
|
+
return svg
|
|
277
|
+
new_w, new_h = w + abs(x), h + abs(y)
|
|
278
|
+
return svg.replace(m.group(0), f'viewBox="0 0 {new_w:g} {new_h:g}"', 1)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _escape(s: str) -> str:
|
|
282
|
+
return s.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _video_poster(src: str) -> str:
|
|
286
|
+
"""Poster/frame image for a video: same basename, .png extension."""
|
|
287
|
+
return src.rsplit(".", 1)[0] + ".png"
|