first commit

This commit is contained in:
Hermes Agent
2026-05-10 13:52:46 +08:00
commit ccc63d1e70
4583 changed files with 584341 additions and 0 deletions

View File

@@ -0,0 +1,146 @@
---
name: sn-md-to-html-report
description: 将 Markdown 文档转换为美观、舒适、结构清晰、可直接打开的 HTML 长篇报告。适用于把 .md 文件转成 HTML、统一研究报告/行业报告/调研文档版式、生成可离线分享的单文件网页报告、嵌入或校验本地图片、修复 Markdown 表格分隔符导致的错列问题,或优化已有 HTML 报告的阅读留白、图片呈现、目录导航、表格响应式和打印样式。
---
# Markdown 转 HTML 报告
## 默认目标
生成“长篇研究报告阅读版”HTML正文舒展、图片自然插入、表格清晰、目录可用、可离线打开。优先保持内容可信和阅读舒服不做营销页、不做炫技页面。
## 推荐路径
优先使用内置脚本生成稳定结果:
```bash
python3 /path/to/sn-md-to-html-report/scripts/render_report.py input.md output.html
```
常用参数:
- `--embed-images`:将本地图片嵌入 HTML适合单文件分享。默认开启。
- `--no-embed-images`:保留相对图片路径,适合文件夹整体发布。
- `--with-js`:加入阅读进度、目录高亮、返回顶部等轻量交互。
- `--keep-inline-toc`:保留 Markdown 正文中已有的目录;默认会移除正文目录,避免和侧边栏目录重复。
- `--mermaid-source auto|cdn|local|none`:渲染 Markdown 中的 Mermaid 代码块。默认 `auto`,检测到 ```mermaid 代码块时使用 CDN`local` 会引用输出 HTML 同目录下的 `mermaid.min.js``none` 保留为普通代码块。
- `--title-style comfortable`:默认舒适报告模板。
生成后运行图片检查:
```bash
python3 /path/to/sn-md-to-html-report/scripts/check_image_refs.py output.html
```
当输出使用 `--embed-images` 时,检查结果中的本地图片引用数通常为 0这是正常的。
## 工作流程
1. 确定输入 Markdown 和输出 HTML。
- 用户只给输入路径时,在同目录生成同名 `.html`
- 若同名 HTML 已存在,优先换新文件名,除非用户明确要求覆盖。
2. 转换前检查 Markdown。
- 以 Markdown 所在目录作为相对图片基准。
- 将误用的全角表格竖线 `` 修正为半角 `|`,避免表格错列。
- 对“说明文字:”后紧跟 `-``*` 或数字编号列表但中间缺空行的常见写法,转换前补空行,让分点输出渲染为真正列表。
- 如果 Markdown 已有 `## 目录` 且内容是章节锚点列表,默认从正文中移除;侧边栏目录已经提供导航,正文目录会重复占空间。
- 保留原文内容,不总结、不改写、不新增事实。
3. 生成完整 HTML5。
- CSS 内联到 `<style>`,不依赖 CDN、在线字体、外部 CSS。
- 默认不需要 JavaScript只有用户想要阅读进度、目录高亮、返回顶部时加少量原生 JS。
- Markdown 中的 ```mermaid 代码块会转换为 Mermaid 图表容器;如需完全离线分享,使用 `--mermaid-source local` 并将 `mermaid.min.js` 放在输出 HTML 同目录。
- 图片默认嵌入为 `data:image/...`,让 HTML 单文件可独立打开。
4. 自检输出。
- 检查标题、目录、表格数量、图片数量是否与源文档大体一致。
- 检查宽表在移动端可横向滚动,图片不撑破页面。
- 检查 HTML 属性、闭合标签、目录锚点和 CSS 语法。
## 视觉原则
从这次效果中沉淀的默认偏好:
- 页面像“干净的研究报告”,不是后台表格页,也不是营销落地页。
- 使用浅灰页面背景和白色正文纸张区,正文有明确边界但不过度卡片化。
- 桌面端左侧使用粘性目录,正文在右侧;移动端目录放到正文上方或可折叠。
- 正文中已有的 Markdown 目录默认不保留,除非用户明确需要正文目录或使用 `--keep-inline-toc`
- H1 可以使用克制的蓝绿渐变标题区,正文标题保持清晰层级。
- 正文留白要舒适:段落、表格、图片之间要让读者有停顿。
- 图片作为图表节点自然出现:居中、最大宽度 100%、轻边框、轻阴影、与上下文留足间距。
- 表格适合研究报告扫描:表头浅色或深色皆可,但要稳定、可横向滚动、单元格内边距足够。
- 配色避免单一深蓝/紫色压满页面;推荐蓝绿主色配少量蓝色链接。
## 舒适模板要点
默认 CSS 应接近以下参数,可按内容微调:
- `body``line-height: 1.75`,浅灰背景,系统中文字体。
- `.layout`:桌面端 `grid-template-columns: minmax(220px, 280px) minmax(0, 1fr)`,最大宽度约 `1480px`,页面外边距约 `28px`
- `.toc-panel`:粘性、半透明白底、细边框、轻阴影;目录项字号约 `13px`
- `main`:白色正文容器、`8px` 圆角、细边框、轻阴影。
- `article`:桌面端内边距约 `46px min(6vw, 76px) 68px`
- `h1`:标题区可用 `linear-gradient(135deg, #0f766e, #155e75, #1d4ed8)`,字号 `clamp(30px, 4vw, 52px)`
- `h2`:上方留足空间,顶部细分割线,字号 `clamp(22px, 2.3vw, 30px)`
- `h3`:字号约 `21px`,颜色比正文更深。
- `blockquote`:用浅蓝绿背景和左侧强调线,可承载图注或注释。
- `.table-scroll`:作为表格外层滚动容器,`width:100%``overflow-x:auto`、细边框和圆角。
- `table`:保持原生 `display:table``width:100%`,不要用 `display:block`;短表要自然铺满正文宽度,宽表由 `.table-scroll` 横向滚动。
- `img``display:block; max-width:100%; height:auto; margin:24px auto 8px; border:1px solid var(--line); border-radius:8px; box-shadow:0 12px 28px rgba(15,23,42,.08)`
## 图片规则
- 默认嵌入本地图片,适合给别人发一个 HTML 文件。
- 用户要求保持文件较小、图片可替换、或已有发布目录时,使用 `--no-embed-images` 保留相对路径。
- 不要使用绝对本地路径写入 HTML除非用户明确要求。
- Markdown 图片后的引用块若明显是图注,保留为图下说明或 blockquote不改变文字。
- 缺失图片要在最终回复里说明路径。
## 表格规则
- 修复全角竖线 ``
- 修复列表前缺空行导致的 Markdown 分点不渲染问题;不要碰代码块里的内容。
- 使用 Markdown 解析器或结构化转换,不用脆弱的字符串拼 HTML 表格。
- 宽表必须可横向滚动,不能挤压正文,也不能在移动端撑破页面。
- 短表不要在右侧留下大片空白;表格元素保持 `width:100%`,滚动只放在外层容器。
- 不要为了美化删列、合并列或改写单元格内容。
## 交互规则
默认静态 HTML 已足够。只有在用户要求“导航更方便”“像原报告一样有进度条”或文档特别长时,加入 `--with-js`
- 阅读进度条。
- 当前目录项高亮。
- 返回顶部按钮。
- 移动端目录展开/收起。
所有交互使用原生 JavaScript不依赖外部库脚本要先检查元素存在。
## 打印规则
添加 `@media print`
- 隐藏目录、进度条、返回顶部等辅助 UI。
- 页面背景改白,去掉阴影。
- 尽量避免表格、图片、引用块被不自然截断。
- 打印时标题不依赖深色渐变背景。
## 质量自检
生成后至少确认:
- HTML 文件存在且可读。
- `<table>``<img>` 数量与源文档大体一致。
- 分点内容应渲染为 `<ul>/<ol>``<li>`,不要保留成带连字符的普通段落。
- 表格应由 `.table-scroll` 包裹,`table` 自身不要使用 `display:block`
- 嵌入图片时包含 `data:image/`;非嵌入时 `check_image_refs.py` 无缺失图片。
- 目录链接均为 `href="#..."` 且目标 ID 存在。
- 正文不应同时出现一份原始 Markdown 目录和一份侧边栏目录,除非用户明确要求保留。
- 不出现破损标签、空 `href`、重复明显 ID、非法 `calc()` 或损坏 CSS。
## 最终回复
简洁说明:
- 输出 HTML 路径。
- 是否嵌入图片,或图片引用检查结果。
- 修复了哪些格式问题,例如全角表格竖线。
- 未能完成的校验,如有。

View File

@@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""检查 Markdown 或 HTML 文件中的本地图片引用。"""
from __future__ import annotations
import argparse
import html
import re
import sys
from pathlib import Path
from urllib.parse import unquote, urlparse
MD_IMAGE_RE = re.compile(r"!\[[^\]]*\]\(([^)\s]+)(?:\s+\"[^\"]*\")?\)")
HTML_IMAGE_RE = re.compile(r"<img\b[^>]*\bsrc=[\"']([^\"']+)[\"']", re.IGNORECASE)
def is_remote_or_data(src: str) -> bool:
parsed = urlparse(src)
return parsed.scheme in {"http", "https", "data", "mailto"} or src.startswith("//")
def clean_src(src: str) -> str:
src = html.unescape(src.strip())
src = src.split("#", 1)[0].split("?", 1)[0]
return unquote(src)
def refs_for_text(text: str) -> list[str]:
refs = []
refs.extend(match.group(1) for match in MD_IMAGE_RE.finditer(text))
refs.extend(match.group(1) for match in HTML_IMAGE_RE.finditer(text))
return refs
def main() -> int:
parser = argparse.ArgumentParser(description="检查 Markdown 或 HTML 文件中的本地图片引用。")
parser.add_argument("file", help="要检查的 Markdown 或 HTML 文件")
args = parser.parse_args()
target = Path(args.file).expanduser().resolve()
if not target.exists():
print(f"错误:文件不存在:{target}", file=sys.stderr)
return 2
text = target.read_text(encoding="utf-8", errors="replace")
base_dir = target.parent
refs = refs_for_text(text)
local_refs = []
missing = []
for raw in refs:
src = clean_src(raw)
if not src or is_remote_or_data(src):
continue
ref_path = Path(src)
if not ref_path.is_absolute():
ref_path = base_dir / ref_path
exists = ref_path.exists()
local_refs.append((src, exists, ref_path))
if not exists:
missing.append((src, ref_path))
print(f"文件:{target}")
print(f"本地图片引用数:{len(local_refs)}")
print(f"缺失图片数:{len(missing)}")
for src, exists, ref_path in local_refs:
status = "正常" if exists else "缺失"
print(f"{status}: {src} -> {ref_path}")
return 1 if missing else 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,533 @@
#!/usr/bin/env python3
"""Render a Markdown research report as a comfortable standalone HTML file."""
from __future__ import annotations
import argparse
import base64
import html
import mimetypes
import re
import sys
from pathlib import Path
try:
import markdown
except ImportError: # pragma: no cover - environment guidance
print("Missing dependency: python package 'markdown'. Install it and rerun.", file=sys.stderr)
raise
MD_IMAGE_RE = re.compile(r"!\[([^\]]*)\]\(([^)\s]+)(?:\s+\"[^\"]*\")?\)")
LIST_ITEM_RE = re.compile(r"^\s*(?:[-*+]|\d+[.)])\s+")
TOC_HEADING_RE = re.compile(r"^\s{0,3}#{2,6}\s+(?:目录|目錄|contents?|table of contents)\s*$", re.IGNORECASE)
TOC_ITEM_RE = re.compile(r"^\s*(?:[-*+]|\d+[.)])\s+\[[^\]]+\]\(#[^)]+\)\s*$")
HR_RE = re.compile(r"^\s{0,3}(?:-{3,}|\*{3,}|_{3,})\s*$")
MERMAID_BLOCK_RE = re.compile(
r'<pre><code class="(?:[^"]*\s)?language-mermaid(?:\s[^"]*)?">(.*?)</code></pre>',
re.S,
)
MERMAID_CDN = "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"
def is_external(src: str) -> bool:
return bool(re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*:", src)) or src.startswith("//")
def embed_images(text: str, base_dir: Path) -> str:
def replace(match: re.Match[str]) -> str:
alt, src = match.groups()
if is_external(src):
return match.group(0)
image_path = (base_dir / src).resolve()
if not image_path.exists():
return match.group(0)
mime = mimetypes.guess_type(image_path.name)[0] or "application/octet-stream"
encoded = base64.b64encode(image_path.read_bytes()).decode("ascii")
return f"![{alt}](data:{mime};base64,{encoded})"
return MD_IMAGE_RE.sub(replace, text)
def normalize_markdown(text: str) -> str:
"""Make common report Markdown patterns parse consistently.
Many generated reports write "label:" directly followed by a list with no
blank line. Python-Markdown treats that as a paragraph plus literal hyphens,
so add the blank line that Markdown parsers expect. Skip fenced code blocks.
"""
text = text.replace("", "|")
lines = text.splitlines()
normalized: list[str] = []
in_fence = False
for i, line in enumerate(lines):
stripped = line.strip()
if stripped.startswith("```") or stripped.startswith("~~~"):
in_fence = not in_fence
if (
not in_fence
and LIST_ITEM_RE.match(line)
and normalized
and normalized[-1].strip()
and not LIST_ITEM_RE.match(normalized[-1])
):
normalized.append("")
normalized.append(line)
next_line = lines[i + 1] if i + 1 < len(lines) else ""
if (
not in_fence
and LIST_ITEM_RE.match(line)
and next_line.strip()
and not LIST_ITEM_RE.match(next_line)
and not next_line.startswith((" ", "\t"))
):
normalized.append("")
return "\n".join(normalized) + ("\n" if text.endswith("\n") else "")
def strip_inline_toc(text: str) -> str:
"""Remove a generated Markdown TOC when a side TOC will be rendered."""
lines = text.splitlines()
stripped: list[str] = []
i = 0
in_fence = False
while i < len(lines):
line = lines[i]
marker = line.strip()
if marker.startswith("```") or marker.startswith("~~~"):
in_fence = not in_fence
stripped.append(line)
i += 1
continue
if not in_fence and TOC_HEADING_RE.match(line):
j = i + 1
while j < len(lines) and not lines[j].strip():
j += 1
item_count = 0
while j < len(lines) and TOC_ITEM_RE.match(lines[j]):
item_count += 1
j += 1
if item_count >= 2:
while j < len(lines) and not lines[j].strip():
j += 1
if j < len(lines) and HR_RE.match(lines[j]):
j += 1
while j < len(lines) and not lines[j].strip():
j += 1
i = j
continue
stripped.append(line)
i += 1
return "\n".join(stripped) + ("\n" if text.endswith("\n") else "")
def title_from_body(body: str) -> str:
match = re.search(r"<h1[^>]*>(.*?)</h1>", body, re.S)
if not match:
return "Markdown Report"
return re.sub(r"<.*?>", "", match.group(1)).strip() or "Markdown Report"
def render_mermaid_blocks(body: str) -> tuple[str, int]:
"""Convert fenced mermaid code blocks into Mermaid render targets."""
def replace(match: re.Match[str]) -> str:
diagram = html.unescape(match.group(1)).strip()
return f'<div class="mermaid">{html.escape(diagram)}</div>'
return MERMAID_BLOCK_RE.subn(replace, body)
def build_mermaid_js(source: str) -> str:
if source == "none":
return ""
if source == "local":
loader = '<script src="mermaid.min.js"></script>'
else:
loader = f'<script src="{MERMAID_CDN}"></script>'
return f"""
{loader}
<script>
(() => {{
if (!window.mermaid) return;
window.mermaid.initialize({{
startOnLoad: true,
securityLevel: 'loose',
theme: 'base',
themeVariables: {{
primaryColor: '#eef7f5',
primaryTextColor: '#1c2430',
primaryBorderColor: '#0f766e',
lineColor: '#2563eb',
secondaryColor: '#eef4f8',
tertiaryColor: '#ffffff',
mainBkg: '#ffffff',
clusterBkg: '#fbfcfe',
clusterBorder: '#dbe2ea',
edgeLabelBackground: '#ffffff',
textColor: '#1c2430',
titleColor: '#0f172a',
nodeTextColor: '#1c2430',
xyChart: {{
backgroundColor: '#fbfcfe',
titleColor: '#0f172a',
xAxisLabelColor: '#475467',
xAxisTitleColor: '#344054',
xAxisTickColor: '#dbe2ea',
xAxisLineColor: '#dbe2ea',
yAxisLabelColor: '#475467',
yAxisTitleColor: '#344054',
yAxisTickColor: '#dbe2ea',
yAxisLineColor: '#dbe2ea',
plotColorPalette: '#0f766e, #2563eb, #94a3b8, #c2410c'
}},
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif'
}}
}});
}})();
</script>
"""
def build_js() -> str:
return """
<script>
(() => {
const progress = document.querySelector('.progress');
const topBtn = document.querySelector('.back-top');
const links = [...document.querySelectorAll('.toc-panel a[href^="#"]')];
const headings = links
.map(a => document.getElementById(decodeURIComponent(a.hash.slice(1))))
.filter(Boolean);
function onScroll() {
const max = document.documentElement.scrollHeight - innerHeight;
if (progress) progress.style.width = max > 0 ? `${scrollY / max * 100}%` : '0%';
if (topBtn) topBtn.classList.toggle('show', scrollY > innerHeight);
let current = headings[0];
for (const h of headings) {
if (h.getBoundingClientRect().top <= 120) current = h;
}
links.forEach(a => a.classList.toggle('active', current && a.hash === `#${current.id}`));
}
addEventListener('scroll', onScroll, { passive: true });
topBtn?.addEventListener('click', () => scrollTo({ top: 0, behavior: 'smooth' }));
onScroll();
})();
</script>
"""
def build_html(title: str, toc: str, body: str, with_js: bool, mermaid_source: str = "none") -> str:
progress = '<div class="progress"></div>' if with_js else ""
back_top = '<button class="back-top" type="button" aria-label="返回顶部">↑</button>' if with_js else ""
js = build_js() if with_js else ""
mermaid_js = build_mermaid_js(mermaid_source)
return f"""<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title}</title>
<style>
:root {{
--bg: #f7f8fb;
--paper: #ffffff;
--ink: #1c2430;
--muted: #667085;
--line: #dbe2ea;
--accent: #0f766e;
--accent-2: #2563eb;
--soft: #eef7f5;
--shadow: 0 18px 45px rgba(15, 23, 42, .08);
--radius: 8px;
}}
* {{ box-sizing: border-box; }}
html {{ scroll-behavior: smooth; }}
body {{
margin: 0;
color: var(--ink);
background:
radial-gradient(circle at 12% 0%, rgba(15, 118, 110, .09), transparent 30%),
linear-gradient(180deg, #f3f7fa 0%, var(--bg) 360px, var(--bg) 100%);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
line-height: 1.75;
letter-spacing: 0;
}}
a {{ color: var(--accent-2); text-decoration: none; }}
a:hover {{ text-decoration: underline; }}
.progress {{ position: fixed; inset: 0 auto auto 0; width: 0; height: 3px; z-index: 10; background: linear-gradient(90deg, var(--accent), var(--accent-2)); }}
.layout {{
display: grid;
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
gap: 28px;
max-width: 1480px;
margin: 0 auto;
padding: 28px;
}}
.toc-panel {{
position: sticky;
top: 20px;
align-self: start;
max-height: calc(100vh - 40px);
overflow: auto;
padding: 18px 16px;
background: rgba(255, 255, 255, .86);
border: 1px solid rgba(219, 226, 234, .9);
border-radius: var(--radius);
box-shadow: 0 10px 30px rgba(15, 23, 42, .05);
backdrop-filter: blur(12px);
}}
.toc-title {{
margin: 0 0 10px;
font-size: 13px;
font-weight: 700;
color: var(--accent);
text-transform: uppercase;
}}
.toc-panel ul {{ list-style: none; padding-left: 0; margin: 0; }}
.toc-panel li {{ margin: 3px 0; }}
.toc-panel ul ul {{ padding-left: 14px; margin-top: 3px; border-left: 1px solid var(--line); }}
.toc-panel a {{
display: block;
padding: 5px 6px;
border-radius: 6px;
color: #425466;
font-size: 13px;
line-height: 1.45;
}}
.toc-panel a:hover, .toc-panel a.active {{ background: var(--soft); color: var(--accent); text-decoration: none; }}
main {{
min-width: 0;
background: var(--paper);
border: 1px solid rgba(219, 226, 234, .9);
border-radius: var(--radius);
box-shadow: var(--shadow);
overflow: hidden;
}}
article {{ padding: 46px min(6vw, 76px) 68px; }}
h1 {{
margin: -46px min(-6vw, -76px) 34px;
padding: 58px min(6vw, 76px) 44px;
color: #fff;
background: linear-gradient(135deg, #0f766e 0%, #155e75 52%, #1d4ed8 100%);
font-size: clamp(30px, 4vw, 52px);
line-height: 1.14;
font-weight: 800;
}}
h2 {{
margin: 54px 0 18px;
padding-top: 8px;
border-top: 1px solid var(--line);
font-size: clamp(22px, 2.3vw, 30px);
line-height: 1.35;
color: #0f172a;
}}
h3 {{ margin: 34px 0 12px; font-size: 21px; color: #17324d; }}
h4 {{ margin: 26px 0 10px; font-size: 17px; color: #344054; }}
p {{ margin: 12px 0; }}
strong {{ color: #0f172a; font-weight: 700; }}
hr {{ border: 0; border-top: 1px solid var(--line); margin: 28px 0; }}
blockquote {{
margin: 18px 0 24px;
padding: 12px 16px;
color: #475467;
background: var(--soft);
border-left: 4px solid var(--accent);
border-radius: 0 var(--radius) var(--radius) 0;
}}
ul, ol {{ padding-left: 1.35em; }}
li {{ margin: 4px 0; }}
.table-scroll {{
width: 100%;
overflow-x: auto;
margin: 18px 0 28px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: #fff;
}}
table {{
width: 100%;
border-collapse: collapse;
margin: 0;
font-size: 14px;
line-height: 1.55;
table-layout: auto;
}}
th, td {{
min-width: 112px;
padding: 11px 13px;
border-bottom: 1px solid var(--line);
text-align: left;
vertical-align: top;
background: #fff;
}}
th {{
color: #0f172a;
background: #eef4f8;
font-weight: 700;
white-space: nowrap;
}}
tr:nth-child(even) td {{ background: #fbfcfe; }}
tr:last-child td {{ border-bottom: 0; }}
img {{
display: block;
max-width: 100%;
height: auto;
margin: 24px auto 8px;
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: 0 12px 28px rgba(15, 23, 42, .08);
background: #fff;
}}
code {{
padding: 2px 5px;
border-radius: 5px;
background: #f1f5f9;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: .92em;
}}
pre {{ overflow: auto; padding: 16px; background: #0f172a; color: #e5e7eb; border-radius: var(--radius); }}
pre code {{ padding: 0; color: inherit; background: transparent; }}
.mermaid {{
margin: 26px 0 30px;
padding: 18px;
overflow-x: auto;
text-align: center;
background: #fbfcfe;
border: 1px solid var(--line);
border-radius: var(--radius);
}}
.mermaid svg {{ max-width: 100%; height: auto; }}
.back-top {{
display: none;
position: fixed;
right: 18px;
bottom: 18px;
width: 42px;
height: 42px;
border: 0;
border-radius: 999px;
color: #fff;
background: var(--accent);
box-shadow: 0 8px 22px rgba(15, 23, 42, .2);
cursor: pointer;
}}
.back-top.show {{ display: block; }}
@media (max-width: 1020px) {{
.layout {{ display: block; padding: 14px; }}
.toc-panel {{ position: relative; top: 0; max-height: 280px; margin-bottom: 14px; }}
article {{ padding: 28px 18px 42px; }}
h1 {{ margin: -28px -18px 28px; padding: 38px 18px 32px; }}
th, td {{ min-width: 120px; padding: 10px; }}
}}
@media print {{
body {{ background: #fff; }}
.layout {{ display: block; max-width: none; padding: 0; }}
.toc-panel, .progress, .back-top {{ display: none !important; }}
main {{ border: 0; box-shadow: none; }}
article {{ padding: 0; }}
h1 {{ margin: 0 0 24px; color: #111827; background: none; padding: 0; }}
a {{ color: inherit; }}
.table-scroll, table {{ page-break-inside: avoid; }}
img, blockquote, pre {{ page-break-inside: avoid; box-shadow: none; }}
}}
</style>
</head>
<body>
{progress}
<div class="layout">
<aside class="toc-panel" aria-label="目录">
<p class="toc-title">目录</p>
{toc}
</aside>
<main>
<article>
{body}
</article>
</main>
</div>
{back_top}
{mermaid_js}
{js}
</body>
</html>
"""
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("input", help="Input Markdown file")
parser.add_argument("output", nargs="?", help="Output HTML file")
parser.add_argument("--embed-images", dest="embed_images", action="store_true", default=True)
parser.add_argument("--no-embed-images", dest="embed_images", action="store_false")
parser.add_argument("--with-js", action="store_true", help="Add progress, active TOC, and back-to-top interactions")
parser.add_argument("--keep-inline-toc", action="store_true", help="Keep an existing Markdown TOC in the article body")
parser.add_argument(
"--mermaid-source",
choices=["auto", "cdn", "local", "none"],
default="auto",
help="Render mermaid fences with CDN JS, local mermaid.min.js, or disable rendering",
)
parser.add_argument("--title-style", choices=["comfortable"], default="comfortable")
args = parser.parse_args()
source = Path(args.input).expanduser().resolve()
if not source.exists():
print(f"Input file not found: {source}", file=sys.stderr)
return 2
output = Path(args.output).expanduser().resolve() if args.output else source.with_suffix(".html")
text = normalize_markdown(source.read_text(encoding="utf-8"))
if not args.keep_inline_toc:
text = strip_inline_toc(text)
if args.embed_images:
text = embed_images(text, source.parent)
md = markdown.Markdown(
extensions=["extra", "toc", "sane_lists", "smarty"],
extension_configs={"toc": {"permalink": False, "separator": "-"}},
)
body = md.convert(text)
body = re.sub(r"(<table>.*?</table>)", r'<div class="table-scroll">\1</div>', body, flags=re.S)
body, mermaid_count = render_mermaid_blocks(body)
mermaid_source = "none"
if mermaid_count and args.mermaid_source != "none":
mermaid_source = "cdn" if args.mermaid_source == "auto" else args.mermaid_source
html = build_html(title_from_body(body), md.toc, body, args.with_js, mermaid_source)
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(html, encoding="utf-8")
print(output)
print(f"tables={html.count('<table')}")
print(f"images={html.count('<img')}")
print(f"embedded_images={html.count('data:image/')}")
print(f"mermaid={mermaid_count}")
if mermaid_source == "cdn":
print("mermaid_source=cdn")
elif mermaid_source == "local":
print("mermaid_source=local")
return 0
if __name__ == "__main__":
raise SystemExit(main())