This commit is contained in:
xunbu
2026-01-06 23:25:03 +08:00
parent 5725a298a0
commit 5a98578596
2 changed files with 139 additions and 223 deletions

View File

@@ -1,6 +1,5 @@
# SPDX-FileCopyrightText: 2025 QinHan
# SPDX-License-Identifier: MPL-2.0
import re # <--- 步骤 1: 导入 re 模块
from dataclasses import dataclass
import jinja2
import markdown
@@ -15,6 +14,17 @@ class MD2HTMLExporterConfig(MDExporterConfig):
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):
def __init__(self, config: MD2HTMLExporterConfig = None):
config = config or MD2HTMLExporterConfig()
@@ -22,70 +32,38 @@ class MD2HTMLExporter(MDExporter):
self.cdn = config.cdn
def export(self, document: MarkdownDocument) -> Document:
cdn = self.cdn
html_template = resource_path("template/markdown.html").read_text(encoding="utf-8")
# CDN 基础 URL
cdn_base = "https://s4.zstatic.net/ajax/libs"
def fetch_text(url_or_path: str) -> str:
"""从 URL 或本地文件获取文本内容"""
# 检测 CDN 是否可用
def can_access_cdn(url: str) -> bool:
try:
if url_or_path.startswith("http"):
import httpx
response = httpx.get(url_or_path, timeout=10.0)
response.raise_for_status()
return response.text
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 ""
import httpx
response = httpx.get(url, timeout=2.0)
return response.status_code == 200
except:
return False
# 辅助函数:将 CSS 中的字体 URL 替换为 CDN 链接
def replace_font_urls(css_content: str) -> str:
"""将 CSS 中的 url(fonts/xxx) 替换为 CDN URL"""
def replace(match):
url_path = match.group(1)
if 'fonts/' in url_path:
font_filename = url_path.split('/')[-1]
return f'url({cdn_base}/KaTeX/0.16.9/fonts/{font_filename})'
return match.group(0)
return re.sub(r'url\(([^)]*fonts/[^)]*)\)', replace, css_content)
# CDN 可用时直接用链接
if self.cdn and can_access_cdn(f"{cdn_base}/KaTeX/0.16.9/katex.min.js"):
pico = r'<link rel="stylesheet" href="https://s4.zstatic.net/ajax/libs/picocss/2.1.1/pico.min.css" />'
katex_css = r'<link rel="stylesheet" href="https://s4.zstatic.net/ajax/libs/KaTeX/0.16.9/katex.min.css" />'
katex_js = r'<script src="https://s4.zstatic.net/ajax/libs/KaTeX/0.16.9/katex.min.js"></script>'
copy_tex_css = r'<link rel="stylesheet" href="https://s4.zstatic.net/ajax/libs/KaTeX/0.16.9/contrib/copy-tex.min.css" />'
copy_tex_js = r'<script src="https://s4.zstatic.net/ajax/libs/KaTeX/0.16.9/contrib/copy-tex.min.js"></script>'
auto_render = r'<script src="https://s4.zstatic.net/ajax/libs/KaTeX/0.16.9/contrib/auto-render.min.js"></script>'
mermaid = r'<script src="https://s4.zstatic.net/ajax/libs/mermaid/10.6.1/mermaid.min.js"></script>'
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"""
<script>
document.addEventListener("DOMContentLoaded", function () {
@@ -96,20 +74,13 @@ class MD2HTMLExporter(MDExporter):
],
throwOnError: false,
errorColor: '#F5CF27',
macros: {
"\\f": "#1f(#2)"
},
macros: { "\\f": "#1f(#2)" },
trust: true,
strict: false
})
});
</script>"""
# mermaid JS
mermaid_url = f"{cdn_base}/mermaid/10.6.1/mermaid.min.js"
mermaid = tag(fetch_text(mermaid_url), "script")
# 扩展配置
extensions = [
'markdown.extensions.tables',
'pymdownx.arithmatex',

View File

@@ -12,203 +12,148 @@
<style>
@page {
margin: 1.5cm 1cm 1.5cm 2cm;
@top-left {
content: "{{title}}";
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;
}
@top-left { content: "{{title}}"; 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 {
#toc-toggle, #toc-sidebar {
display: none !important;
}
html {
padding: 0 !important;
}
main {
padding: 0 !important;
}
#toc-btn, #toc-sidebar { display: none !important; }
html, main { padding: 0 !important; }
}
html {
font-size: 15px;
}
html { font-size: 15px; }
main { padding: 2vh 10vw; }
main {
padding: 2vh 10vw;
}
#toc-toggle {
position: fixed;
left: 10px;
top: 50%;
transform: translateY(-50%);
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 {
position: fixed; left: 10px; top: 50%; transform: translateY(-50%);
width: 30px; height: 50px;
background: #f8f8f8; border: 1px solid #ddd; border-radius: 0 4px 4px 0;
cursor: pointer; z-index: 1000;
display: flex; align-items: center; justify-content: center;
font-size: 14px; color: #666;
opacity: 0; transition: left 0.3s ease, opacity 0.2s;
}
#toc-btn:hover { background: #eee; }
#toc-btn.visible { opacity: 1; }
#toc-btn.open { left: 270px; }
/* 侧边栏 */
#toc-sidebar {
position: fixed;
left: -280px;
top: 0;
width: 280px;
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;
position: fixed; left: -260px; top: 0; width: 260px; height: 100vh;
background: #fafafa; border-right: 1px solid #ddd;
z-index: 999; transition: left 0.3s ease;
padding: 15px; box-sizing: border-box;
}
#toc-sidebar.open { left: 0; }
#toc-sidebar h3 {
margin-top: 0;
margin-bottom: 15px;
margin: 0 0 15px;
font-size: 16px;
color: #333;
border-bottom: 1px solid #ddd;
padding-bottom: 10px;
border-bottom: 1px solid #e0e0e0;
}
#toc-list {
list-style: none;
padding: 0;
margin: 0;
}
#toc-list li {
margin: 5px 0;
font-size: 13px;
}
#toc-list { list-style: none; padding: 0; margin: 0; }
#toc-list li { margin: 2px 0; }
#toc-list li a {
color: #555;
text-decoration: none;
display: block;
padding: 3px 0;
padding: 6px 10px;
border-radius: 4px;
font-size: 14px;
transition: background 0.15s;
}
#toc-list li a:hover {
color: #007bff;
background: #e8e8e8;
color: #0066cc;
}
#toc-list .toc-h1 { margin-left: 0; font-weight: 600; }
#toc-list .toc-h2 { margin-left: 15px; }
#toc-list .toc-h3 { margin-left: 30px; font-size: 12px; }
#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-h6 { margin-left: 75px; font-size: 11px; color: #888; }
#toc-list .toc-h1 {
font-weight: 600;
font-size: 15px;
margin-top: 8px;
}
#toc-list .toc-h1 > a { color: #333; }
#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>
</head>
<body>
<!-- Toggle Button -->
<div id="toc-toggle" title="目录"></div>
<!-- Sidebar TOC -->
<!-- 目录按钮 -->
<div id="toc-btn" title="目录"></div>
<!-- 侧边栏 -->
<div id="toc-sidebar">
<h3>目录</h3>
<ul id="toc-list"></ul>
</div>
<!-- Main Content -->
<main>
{{markdown}}
</main>
<main>{{markdown}}</main>
</body>
{{renderMathInElement}}
{{mermaid}}
<script>
// Generate TOC from headings
function generateTOC() {
const main = document.querySelector('main');
const headings = main.querySelectorAll('h1, h2, h3, h4, h5, h6');
const tocList = document.getElementById('toc-list');
(function(){
const btn = document.getElementById('toc-btn');
const sidebar = document.getElementById('toc-sidebar');
const list = document.getElementById('toc-list');
const main = document.querySelector('main');
if (headings.length === 0) {
document.getElementById('toc-toggle').style.display = 'none';
return;
}
// 生成目录
const headings = main.querySelectorAll('h1, h2, h3, h4');
if (headings.length === 0) { btn.style.display = 'none'; return; }
headings.forEach((heading, index) => {
if (!heading.id) {
heading.id = 'heading-' + index;
}
const level = heading.tagName.toLowerCase();
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
headings.forEach((h, i) => {
if (!h.id) h.id = 'h-' + i;
const li = document.createElement('li');
li.className = 'toc-' + h.tagName.toLowerCase();
li.innerHTML = '<a href="#' + h.id + '">' + h.textContent + '</a>';
list.appendChild(li);
});
</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>