update
This commit is contained in:
@@ -1,6 +1,5 @@
|
|||||||
# SPDX-FileCopyrightText: 2025 QinHan
|
# SPDX-FileCopyrightText: 2025 QinHan
|
||||||
# SPDX-License-Identifier: MPL-2.0
|
# SPDX-License-Identifier: MPL-2.0
|
||||||
import re # <--- 步骤 1: 导入 re 模块
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
import jinja2
|
import jinja2
|
||||||
import markdown
|
import markdown
|
||||||
@@ -15,6 +14,17 @@ class MD2HTMLExporterConfig(MDExporterConfig):
|
|||||||
cdn: bool = True
|
cdn: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
# 预读取本地静态文件(加速)
|
||||||
|
_LOCAL_CACHE = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_local_content(path: str) -> str:
|
||||||
|
"""从本地读取文件内容,使用缓存加速"""
|
||||||
|
if path not in _LOCAL_CACHE:
|
||||||
|
_LOCAL_CACHE[path] = resource_path(path).read_text(encoding="utf-8")
|
||||||
|
return _LOCAL_CACHE[path]
|
||||||
|
|
||||||
|
|
||||||
class MD2HTMLExporter(MDExporter):
|
class MD2HTMLExporter(MDExporter):
|
||||||
def __init__(self, config: MD2HTMLExporterConfig = None):
|
def __init__(self, config: MD2HTMLExporterConfig = None):
|
||||||
config = config or MD2HTMLExporterConfig()
|
config = config or MD2HTMLExporterConfig()
|
||||||
@@ -22,70 +32,38 @@ class MD2HTMLExporter(MDExporter):
|
|||||||
self.cdn = config.cdn
|
self.cdn = config.cdn
|
||||||
|
|
||||||
def export(self, document: MarkdownDocument) -> Document:
|
def export(self, document: MarkdownDocument) -> Document:
|
||||||
cdn = self.cdn
|
|
||||||
html_template = resource_path("template/markdown.html").read_text(encoding="utf-8")
|
html_template = resource_path("template/markdown.html").read_text(encoding="utf-8")
|
||||||
|
|
||||||
# CDN 基础 URL
|
|
||||||
cdn_base = "https://s4.zstatic.net/ajax/libs"
|
cdn_base = "https://s4.zstatic.net/ajax/libs"
|
||||||
|
|
||||||
def fetch_text(url_or_path: str) -> str:
|
# 检测 CDN 是否可用
|
||||||
"""从 URL 或本地文件获取文本内容"""
|
def can_access_cdn(url: str) -> bool:
|
||||||
try:
|
try:
|
||||||
if url_or_path.startswith("http"):
|
import httpx
|
||||||
import httpx
|
response = httpx.get(url, timeout=2.0)
|
||||||
response = httpx.get(url_or_path, timeout=10.0)
|
return response.status_code == 200
|
||||||
response.raise_for_status()
|
except:
|
||||||
return response.text
|
return False
|
||||||
else:
|
|
||||||
return resource_path(url_or_path).read_text(encoding="utf-8")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Warning: Failed to fetch {url_or_path}: {e}")
|
|
||||||
return ""
|
|
||||||
|
|
||||||
# 辅助函数:将 CSS 中的字体 URL 替换为 CDN 链接
|
# CDN 可用时直接用链接
|
||||||
def replace_font_urls(css_content: str) -> str:
|
if self.cdn and can_access_cdn(f"{cdn_base}/KaTeX/0.16.9/katex.min.js"):
|
||||||
"""将 CSS 中的 url(fonts/xxx) 替换为 CDN URL"""
|
pico = r'<link rel="stylesheet" href="https://s4.zstatic.net/ajax/libs/picocss/2.1.1/pico.min.css" />'
|
||||||
def replace(match):
|
katex_css = r'<link rel="stylesheet" href="https://s4.zstatic.net/ajax/libs/KaTeX/0.16.9/katex.min.css" />'
|
||||||
url_path = match.group(1)
|
katex_js = r'<script src="https://s4.zstatic.net/ajax/libs/KaTeX/0.16.9/katex.min.js"></script>'
|
||||||
if 'fonts/' in url_path:
|
copy_tex_css = r'<link rel="stylesheet" href="https://s4.zstatic.net/ajax/libs/KaTeX/0.16.9/contrib/copy-tex.min.css" />'
|
||||||
font_filename = url_path.split('/')[-1]
|
copy_tex_js = r'<script src="https://s4.zstatic.net/ajax/libs/KaTeX/0.16.9/contrib/copy-tex.min.js"></script>'
|
||||||
return f'url({cdn_base}/KaTeX/0.16.9/fonts/{font_filename})'
|
auto_render = r'<script src="https://s4.zstatic.net/ajax/libs/KaTeX/0.16.9/contrib/auto-render.min.js"></script>'
|
||||||
return match.group(0)
|
mermaid = r'<script src="https://s4.zstatic.net/ajax/libs/mermaid/10.6.1/mermaid.min.js"></script>'
|
||||||
return re.sub(r'url\(([^)]*fonts/[^)]*)\)', replace, css_content)
|
else:
|
||||||
|
# CDN 不可用时,嵌入本地文件
|
||||||
|
pico = f'<style>{_get_local_content("static/pico.css")}</style>'
|
||||||
|
katex_css = f'<style>{_get_local_content("static/katex/katex.css")}</style>'
|
||||||
|
katex_js = f'<script>{_get_local_content("static/katex/katex.js")}</script>'
|
||||||
|
copy_tex_css = f'<style>{_get_local_content("static/katex/copy-tex.min.css")}</style>'
|
||||||
|
copy_tex_js = f'<script>{_get_local_content("static/katex/copy-tex.min.js")}</script>'
|
||||||
|
auto_render = f'<script>{_get_local_content("static/autoRender.js")}</script>'
|
||||||
|
mermaid = f'<script>{_get_local_content("static/mermaid.js")}</script>'
|
||||||
|
|
||||||
# 辅助函数:包装为 style/script 标签
|
|
||||||
def tag(content: str, tag_type: str) -> str:
|
|
||||||
if tag_type == "style":
|
|
||||||
return f"<style>\n{content}\n</style>"
|
|
||||||
return f"<script>\n{content}\n</script>"
|
|
||||||
|
|
||||||
# Pico CSS
|
|
||||||
pico_url = f"{cdn_base}/picocss/2.1.1/pico.min.css"
|
|
||||||
pico = tag(fetch_text(pico_url), "style")
|
|
||||||
|
|
||||||
# KaTeX CSS (字体使用 CDN)
|
|
||||||
katex_css_url = f"{cdn_base}/KaTeX/0.16.9/katex.min.css"
|
|
||||||
katex_css_content = fetch_text(katex_css_url)
|
|
||||||
katex_css_content = replace_font_urls(katex_css_content)
|
|
||||||
katex_css = tag(katex_css_content, "style")
|
|
||||||
|
|
||||||
# KaTeX JS
|
|
||||||
katex_js_url = f"{cdn_base}/KaTeX/0.16.9/katex.min.js"
|
|
||||||
katex_js = tag(fetch_text(katex_js_url), "script")
|
|
||||||
|
|
||||||
# copy-tex CSS
|
|
||||||
copy_tex_css_url = f"{cdn_base}/KaTeX/0.16.9/contrib/copy-tex.min.css"
|
|
||||||
copy_tex_css = tag(fetch_text(copy_tex_css_url), "style")
|
|
||||||
|
|
||||||
# copy-tex JS
|
|
||||||
copy_tex_js_url = f"{cdn_base}/KaTeX/0.16.9/contrib/copy-tex.min.js"
|
|
||||||
copy_tex_js = tag(fetch_text(copy_tex_js_url), "script")
|
|
||||||
|
|
||||||
# auto-render JS
|
|
||||||
auto_render_url = f"{cdn_base}/KaTeX/0.16.9/contrib/auto-render.min.js"
|
|
||||||
auto_render = tag(fetch_text(auto_render_url), "script")
|
|
||||||
|
|
||||||
# renderMathInElement 配置
|
|
||||||
render_math_in_element = r"""
|
render_math_in_element = r"""
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener("DOMContentLoaded", function () {
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
@@ -96,20 +74,13 @@ class MD2HTMLExporter(MDExporter):
|
|||||||
],
|
],
|
||||||
throwOnError: false,
|
throwOnError: false,
|
||||||
errorColor: '#F5CF27',
|
errorColor: '#F5CF27',
|
||||||
macros: {
|
macros: { "\\f": "#1f(#2)" },
|
||||||
"\\f": "#1f(#2)"
|
|
||||||
},
|
|
||||||
trust: true,
|
trust: true,
|
||||||
strict: false
|
strict: false
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
</script>"""
|
</script>"""
|
||||||
|
|
||||||
# mermaid JS
|
|
||||||
mermaid_url = f"{cdn_base}/mermaid/10.6.1/mermaid.min.js"
|
|
||||||
mermaid = tag(fetch_text(mermaid_url), "script")
|
|
||||||
|
|
||||||
# 扩展配置
|
|
||||||
extensions = [
|
extensions = [
|
||||||
'markdown.extensions.tables',
|
'markdown.extensions.tables',
|
||||||
'pymdownx.arithmatex',
|
'pymdownx.arithmatex',
|
||||||
|
|||||||
@@ -12,203 +12,148 @@
|
|||||||
<style>
|
<style>
|
||||||
@page {
|
@page {
|
||||||
margin: 1.5cm 1cm 1.5cm 2cm;
|
margin: 1.5cm 1cm 1.5cm 2cm;
|
||||||
@top-left {
|
@top-left { content: "{{title}}"; font-size: 10pt; color: #666; }
|
||||||
content: "{{title}}";
|
@top-right { content: counter(page); font-size: 10pt; color: #666; }
|
||||||
font-size: 10pt;
|
@bottom-left { content: "Powered by DocuTranslate"; font-size: 8pt; color: #999; }
|
||||||
color: #666;
|
@bottom-right { content: counter(page) " / " counter(pages); font-size: 10pt; color: #666; }
|
||||||
}
|
|
||||||
@top-right {
|
|
||||||
content: counter(page);
|
|
||||||
font-size: 10pt;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
@bottom-left {
|
|
||||||
content: "Powered by DocuTranslate";
|
|
||||||
font-size: 8pt;
|
|
||||||
color: #999;
|
|
||||||
}
|
|
||||||
@bottom-right {
|
|
||||||
content: counter(page) " / " counter(pages);
|
|
||||||
font-size: 10pt;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media print {
|
@media print {
|
||||||
#toc-toggle, #toc-sidebar {
|
#toc-btn, #toc-sidebar { display: none !important; }
|
||||||
display: none !important;
|
html, main { padding: 0 !important; }
|
||||||
}
|
|
||||||
html {
|
|
||||||
padding: 0 !important;
|
|
||||||
}
|
|
||||||
main {
|
|
||||||
padding: 0 !important;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
html {
|
html { font-size: 15px; }
|
||||||
font-size: 15px;
|
main { padding: 2vh 10vw; }
|
||||||
}
|
|
||||||
|
|
||||||
main {
|
/* 目录按钮 */
|
||||||
padding: 2vh 10vw;
|
#toc-btn {
|
||||||
}
|
position: fixed; left: 10px; top: 50%; transform: translateY(-50%);
|
||||||
|
width: 30px; height: 50px;
|
||||||
#toc-toggle {
|
background: #f8f8f8; border: 1px solid #ddd; border-radius: 0 4px 4px 0;
|
||||||
position: fixed;
|
cursor: pointer; z-index: 1000;
|
||||||
left: 10px;
|
display: flex; align-items: center; justify-content: center;
|
||||||
top: 50%;
|
font-size: 14px; color: #666;
|
||||||
transform: translateY(-50%);
|
opacity: 0; transition: left 0.3s ease, opacity 0.2s;
|
||||||
width: 30px;
|
|
||||||
height: 60px;
|
|
||||||
background: #f0f0f0;
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
border-radius: 0 5px 5px 0;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #666;
|
|
||||||
z-index: 1000;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
#toc-toggle:hover {
|
|
||||||
background: #e0e0e0;
|
|
||||||
}
|
|
||||||
|
|
||||||
body:hover #toc-toggle {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
}
|
||||||
|
#toc-btn:hover { background: #eee; }
|
||||||
|
#toc-btn.visible { opacity: 1; }
|
||||||
|
#toc-btn.open { left: 270px; }
|
||||||
|
|
||||||
|
/* 侧边栏 */
|
||||||
#toc-sidebar {
|
#toc-sidebar {
|
||||||
position: fixed;
|
position: fixed; left: -260px; top: 0; width: 260px; height: 100vh;
|
||||||
left: -280px;
|
background: #fafafa; border-right: 1px solid #ddd;
|
||||||
top: 0;
|
z-index: 999; transition: left 0.3s ease;
|
||||||
width: 280px;
|
padding: 15px; box-sizing: border-box;
|
||||||
height: 100vh;
|
|
||||||
background: #fafafa;
|
|
||||||
border-right: 1px solid #ddd;
|
|
||||||
overflow-y: auto;
|
|
||||||
z-index: 999;
|
|
||||||
transition: left 0.3s ease;
|
|
||||||
padding: 20px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
#toc-sidebar.open {
|
|
||||||
left: 0;
|
|
||||||
}
|
}
|
||||||
|
#toc-sidebar.open { left: 0; }
|
||||||
|
|
||||||
#toc-sidebar h3 {
|
#toc-sidebar h3 {
|
||||||
margin-top: 0;
|
margin: 0 0 15px;
|
||||||
margin-bottom: 15px;
|
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
color: #333;
|
color: #333;
|
||||||
border-bottom: 1px solid #ddd;
|
|
||||||
padding-bottom: 10px;
|
padding-bottom: 10px;
|
||||||
|
border-bottom: 1px solid #e0e0e0;
|
||||||
}
|
}
|
||||||
|
|
||||||
#toc-list {
|
#toc-list { list-style: none; padding: 0; margin: 0; }
|
||||||
list-style: none;
|
#toc-list li { margin: 2px 0; }
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#toc-list li {
|
|
||||||
margin: 5px 0;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#toc-list li a {
|
#toc-list li a {
|
||||||
color: #555;
|
color: #555;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
display: block;
|
display: block;
|
||||||
padding: 3px 0;
|
padding: 6px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: background 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
#toc-list li a:hover {
|
#toc-list li a:hover {
|
||||||
color: #007bff;
|
background: #e8e8e8;
|
||||||
|
color: #0066cc;
|
||||||
}
|
}
|
||||||
|
#toc-list .toc-h1 {
|
||||||
#toc-list .toc-h1 { margin-left: 0; font-weight: 600; }
|
font-weight: 600;
|
||||||
#toc-list .toc-h2 { margin-left: 15px; }
|
font-size: 15px;
|
||||||
#toc-list .toc-h3 { margin-left: 30px; font-size: 12px; }
|
margin-top: 8px;
|
||||||
#toc-list .toc-h4 { margin-left: 45px; font-size: 12px; }
|
}
|
||||||
#toc-list .toc-h5 { margin-left: 60px; font-size: 11px; color: #888; }
|
#toc-list .toc-h1 > a { color: #333; }
|
||||||
#toc-list .toc-h6 { margin-left: 75px; font-size: 11px; color: #888; }
|
#toc-list .toc-h2 { margin-left: 0; }
|
||||||
|
#toc-list .toc-h2 > a { padding-left: 24px; font-size: 14px; }
|
||||||
|
#toc-list .toc-h3 { margin-left: 0; }
|
||||||
|
#toc-list .toc-h3 > a { padding-left: 36px; font-size: 13px; color: #666; }
|
||||||
|
#toc-list .toc-h4 { margin-left: 0; }
|
||||||
|
#toc-list .toc-h4 > a { padding-left: 48px; font-size: 12px; color: #888; }
|
||||||
|
#toc-list .toc-h5 > a { padding-left: 60px; font-size: 12px; color: #888; }
|
||||||
|
#toc-list .toc-h6 > a { padding-left: 72px; font-size: 12px; color: #888; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<!-- Toggle Button -->
|
<!-- 目录按钮 -->
|
||||||
<div id="toc-toggle" title="目录">☰</div>
|
<div id="toc-btn" title="目录">☰</div>
|
||||||
|
<!-- 侧边栏 -->
|
||||||
<!-- Sidebar TOC -->
|
|
||||||
<div id="toc-sidebar">
|
<div id="toc-sidebar">
|
||||||
<h3>目录</h3>
|
<h3>目录</h3>
|
||||||
<ul id="toc-list"></ul>
|
<ul id="toc-list"></ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Main Content -->
|
<main>{{markdown}}</main>
|
||||||
<main>
|
|
||||||
{{markdown}}
|
|
||||||
</main>
|
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
{{renderMathInElement}}
|
{{renderMathInElement}}
|
||||||
{{mermaid}}
|
{{mermaid}}
|
||||||
<script>
|
<script>
|
||||||
// Generate TOC from headings
|
(function(){
|
||||||
function generateTOC() {
|
const btn = document.getElementById('toc-btn');
|
||||||
const main = document.querySelector('main');
|
const sidebar = document.getElementById('toc-sidebar');
|
||||||
const headings = main.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
const list = document.getElementById('toc-list');
|
||||||
const tocList = document.getElementById('toc-list');
|
const main = document.querySelector('main');
|
||||||
|
|
||||||
if (headings.length === 0) {
|
// 生成目录
|
||||||
document.getElementById('toc-toggle').style.display = 'none';
|
const headings = main.querySelectorAll('h1, h2, h3, h4');
|
||||||
return;
|
if (headings.length === 0) { btn.style.display = 'none'; return; }
|
||||||
}
|
|
||||||
|
|
||||||
headings.forEach((heading, index) => {
|
headings.forEach((h, i) => {
|
||||||
if (!heading.id) {
|
if (!h.id) h.id = 'h-' + i;
|
||||||
heading.id = 'heading-' + index;
|
const li = document.createElement('li');
|
||||||
}
|
li.className = 'toc-' + h.tagName.toLowerCase();
|
||||||
|
li.innerHTML = '<a href="#' + h.id + '">' + h.textContent + '</a>';
|
||||||
const level = heading.tagName.toLowerCase();
|
list.appendChild(li);
|
||||||
const text = heading.textContent;
|
|
||||||
const li = document.createElement('li');
|
|
||||||
li.className = 'toc-' + level;
|
|
||||||
li.innerHTML = `<a href="#${heading.id}">${text}</a>`;
|
|
||||||
tocList.appendChild(li);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Toggle sidebar
|
|
||||||
function toggleTOC() {
|
|
||||||
const sidebar = document.getElementById('toc-sidebar');
|
|
||||||
sidebar.classList.toggle('open');
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('toc-toggle').addEventListener('click', toggleTOC);
|
|
||||||
|
|
||||||
// Generate TOC after content loads
|
|
||||||
generateTOC();
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
const KatexErrors = document.getElementsByClassName("katex-error")
|
|
||||||
for (const katexError of KatexErrors) {
|
|
||||||
katexError.innerHTML = katexError.title
|
|
||||||
}
|
|
||||||
}, 200)
|
|
||||||
</script>
|
|
||||||
<script>
|
|
||||||
mermaid.initialize({
|
|
||||||
securityLevel: 'loose',
|
|
||||||
startOnLoad: true
|
|
||||||
});
|
});
|
||||||
</script>
|
|
||||||
|
|
||||||
|
// 切换目录
|
||||||
|
function toggleTOC() {
|
||||||
|
const open = sidebar.classList.toggle('open');
|
||||||
|
btn.classList.toggle('open', open);
|
||||||
|
btn.textContent = open ? '✕' : '☰';
|
||||||
|
}
|
||||||
|
btn.onclick = toggleTOC;
|
||||||
|
|
||||||
|
// 左侧滑动/悬停检测
|
||||||
|
let touchStartX = 0;
|
||||||
|
|
||||||
|
document.addEventListener('touchstart', e => { touchStartX = e.touches[0].clientX; });
|
||||||
|
document.addEventListener('touchmove', e => {
|
||||||
|
const x = e.touches[0].clientX;
|
||||||
|
if (touchStartX > 30 && x < 50 && !sidebar.classList.contains('open')) {
|
||||||
|
btn.classList.add('visible');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', e => {
|
||||||
|
if (e.clientX < 50 && !sidebar.classList.contains('open')) {
|
||||||
|
btn.classList.add('visible');
|
||||||
|
} else {
|
||||||
|
btn.classList.remove('visible');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
for (const e of document.getElementsByClassName('katex-error')) e.innerHTML = e.title;
|
||||||
|
}, 200);
|
||||||
|
|
||||||
|
mermaid.initialize({ securityLevel: 'loose', startOnLoad: true });
|
||||||
|
</script>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user