2026-07-24 20:24:39 +08:00
|
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
|
"""
|
|
|
|
|
|
Docs as Code — 构建入口
|
|
|
|
|
|
|
2026-07-26 00:00:24 +08:00
|
|
|
|
读取 project.yaml → 逐章节处理 @import → Markdown → HTML → 图片内嵌
|
|
|
|
|
|
→ 组装 → 注入锚点 → 提取目录 → 模板渲染 → 自包含 HTML 报告。
|
2026-07-24 20:24:39 +08:00
|
|
|
|
|
|
|
|
|
|
用法:
|
|
|
|
|
|
python doc_builder/build.py
|
|
|
|
|
|
|
|
|
|
|
|
输出:
|
2026-07-26 00:00:24 +08:00
|
|
|
|
output/<标题>.html (自包含 HTML,可离线分发)
|
2026-07-24 20:24:39 +08:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
import base64
|
2026-07-26 00:00:24 +08:00
|
|
|
|
import csv
|
|
|
|
|
|
import importlib.util
|
|
|
|
|
|
import io
|
|
|
|
|
|
import json
|
2026-07-24 20:24:39 +08:00
|
|
|
|
import re
|
2026-07-26 00:00:24 +08:00
|
|
|
|
import sys
|
|
|
|
|
|
from datetime import date
|
2026-07-24 20:24:39 +08:00
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
|
|
import markdown
|
2026-07-26 00:00:24 +08:00
|
|
|
|
import yaml
|
|
|
|
|
|
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
|
|
|
|
|
|
|
|
|
|
|
# ---------- 路径配置 ----------
|
|
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
|
|
|
|
|
CHAPTERS_DIR = PROJECT_ROOT / "chapters"
|
|
|
|
|
|
ASSETS_DIR = PROJECT_ROOT / "assets"
|
|
|
|
|
|
OUTPUT_DIR = PROJECT_ROOT / "output"
|
|
|
|
|
|
BUILDER_DIR = PROJECT_ROOT / "doc_builder"
|
|
|
|
|
|
TEMPLATES_DIR = BUILDER_DIR / "templates"
|
|
|
|
|
|
THEMES_DIR = BUILDER_DIR / "themes"
|
|
|
|
|
|
RENDERERS_DIR = BUILDER_DIR / "renderers"
|
|
|
|
|
|
CHECKS_DIR = BUILDER_DIR / "checks"
|
|
|
|
|
|
|
|
|
|
|
|
# ---------- 配置加载 ----------
|
|
|
|
|
|
|
|
|
|
|
|
def load_config():
|
|
|
|
|
|
path = PROJECT_ROOT / "project.yaml"
|
|
|
|
|
|
if not path.exists():
|
|
|
|
|
|
raise FileNotFoundError("找不到 project.yaml")
|
|
|
|
|
|
with open(path, "r", encoding="utf-8") as f:
|
|
|
|
|
|
config = yaml.safe_load(f) or {}
|
|
|
|
|
|
|
|
|
|
|
|
config.setdefault("title", "未命名文档")
|
|
|
|
|
|
config.setdefault("subtitle", "")
|
|
|
|
|
|
config.setdefault("author", "")
|
|
|
|
|
|
config.setdefault("version", "")
|
|
|
|
|
|
config.setdefault("doc_type", "技术文档")
|
|
|
|
|
|
config.setdefault("logo", "")
|
|
|
|
|
|
config.setdefault("lang", "zh-CN")
|
|
|
|
|
|
config.setdefault("date", date.today().isoformat())
|
|
|
|
|
|
return config
|
2026-07-24 20:24:39 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-07-26 00:00:24 +08:00
|
|
|
|
# ---------- 插件发现 ----------
|
2026-07-24 20:24:39 +08:00
|
|
|
|
|
2026-07-26 00:00:24 +08:00
|
|
|
|
def load_plugin_modules(directory):
|
|
|
|
|
|
"""扫描目录下的 *.py 文件并导入为模块字典。"""
|
|
|
|
|
|
modules = {}
|
|
|
|
|
|
if not directory.exists():
|
|
|
|
|
|
return modules
|
|
|
|
|
|
for py_file in sorted(directory.glob("*.py")):
|
|
|
|
|
|
if py_file.name.startswith("_"):
|
|
|
|
|
|
continue
|
|
|
|
|
|
try:
|
|
|
|
|
|
spec = importlib.util.spec_from_file_location(py_file.stem, py_file)
|
|
|
|
|
|
if spec is None or spec.loader is None:
|
|
|
|
|
|
continue
|
|
|
|
|
|
mod = importlib.util.module_from_spec(spec)
|
2026-07-28 12:34:12 +08:00
|
|
|
|
# 注册到 sys.modules 以支持插件间交叉导入
|
|
|
|
|
|
sys.modules[py_file.stem] = mod
|
2026-07-26 00:00:24 +08:00
|
|
|
|
spec.loader.exec_module(mod)
|
|
|
|
|
|
modules[py_file.stem] = mod
|
|
|
|
|
|
print(f" 已加载: {py_file.stem}")
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
print(f" 警告: 加载 {py_file.name} 失败: {e}")
|
2026-07-28 12:34:12 +08:00
|
|
|
|
# 加载失败时清理 sys.modules
|
|
|
|
|
|
sys.modules.pop(py_file.stem, None)
|
2026-07-26 00:00:24 +08:00
|
|
|
|
return modules
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------- @import 处理 ----------
|
|
|
|
|
|
|
|
|
|
|
|
IMPORT_RE = re.compile(r'^@import\s+"([^"]+)"(?:\s+using\s+(\S+))?\s*$', re.MULTILINE)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def resolve_import_path(raw, base_dir):
|
|
|
|
|
|
path = Path(raw)
|
|
|
|
|
|
if path.is_absolute():
|
|
|
|
|
|
return path
|
|
|
|
|
|
return (base_dir / path).resolve()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def default_data_renderer(filepath):
|
|
|
|
|
|
"""默认数据文件渲染:CSV → HTML 表格,YAML/JSON → 代码块。"""
|
|
|
|
|
|
ext = filepath.suffix.lower()
|
|
|
|
|
|
if ext == ".csv":
|
|
|
|
|
|
with open(filepath, newline="", encoding="utf-8") as f:
|
|
|
|
|
|
reader = csv.reader(f)
|
|
|
|
|
|
rows = list(reader)
|
|
|
|
|
|
if not rows:
|
|
|
|
|
|
return ""
|
|
|
|
|
|
lines = ["<table>"]
|
|
|
|
|
|
lines.append("<tr>" + "".join(f"<th>{c}</th>" for c in rows[0]) + "</tr>")
|
|
|
|
|
|
for row in rows[1:]:
|
|
|
|
|
|
lines.append("<tr>" + "".join(f"<td>{c}</td>" for c in row) + "</tr>")
|
|
|
|
|
|
lines.append("</table>")
|
|
|
|
|
|
return "".join(lines)
|
|
|
|
|
|
elif ext in (".yaml", ".yml"):
|
|
|
|
|
|
with open(filepath, "r", encoding="utf-8") as f:
|
|
|
|
|
|
data = yaml.safe_load(f) or {}
|
|
|
|
|
|
return f"<pre><code>{yaml.dump(data, allow_unicode=True)}</code></pre>"
|
|
|
|
|
|
elif ext == ".json":
|
|
|
|
|
|
with open(filepath, "r", encoding="utf-8") as f:
|
|
|
|
|
|
data = json.load(f)
|
|
|
|
|
|
return f"<pre><code>{json.dumps(data, ensure_ascii=False, indent=2)}</code></pre>"
|
|
|
|
|
|
else:
|
|
|
|
|
|
content = filepath.read_text(encoding="utf-8")
|
|
|
|
|
|
return f"<pre><code>{content}</code></pre>"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def render_with_module(renderers, renderer_name, filepath):
|
|
|
|
|
|
"""调用指定渲染器。"""
|
|
|
|
|
|
if renderer_name not in renderers:
|
|
|
|
|
|
raise ValueError(f"找不到指定渲染器:{renderer_name}")
|
|
|
|
|
|
func = getattr(renderers[renderer_name], "render", None)
|
|
|
|
|
|
if not callable(func):
|
|
|
|
|
|
raise ValueError(f"{renderer_name} 没有 render(content: str) -> str 函数")
|
|
|
|
|
|
return func(str(filepath))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def rewrite_image_paths(text, source_dir):
|
|
|
|
|
|
"""把被导入 Markdown 中的相对图片路径改为绝对路径,供后续 base64 嵌入。"""
|
|
|
|
|
|
def repl(match):
|
|
|
|
|
|
alt = match.group(1)
|
|
|
|
|
|
src = match.group(2)
|
|
|
|
|
|
if src.startswith(("http://", "https://", "data:")) or Path(src).is_absolute():
|
|
|
|
|
|
return match.group(0)
|
|
|
|
|
|
abs_path = (source_dir / src).resolve()
|
|
|
|
|
|
return f''
|
|
|
|
|
|
return re.sub(r'!\[([^\]]*)\]\(([^)]+)\)', repl, text)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def process_imports(text, base_dir, renderers, _imported=None):
|
|
|
|
|
|
"""扫描并替换 @import 指令。支持 .md 注入和数据文件导入。"""
|
|
|
|
|
|
if _imported is None:
|
|
|
|
|
|
_imported = set()
|
|
|
|
|
|
|
|
|
|
|
|
def repl(match):
|
|
|
|
|
|
raw = match.group(1)
|
|
|
|
|
|
renderer_name = match.group(2)
|
|
|
|
|
|
target = resolve_import_path(raw, base_dir)
|
|
|
|
|
|
|
|
|
|
|
|
# 外部 Markdown 导入
|
|
|
|
|
|
if raw.endswith(".md"):
|
|
|
|
|
|
if target in _imported:
|
|
|
|
|
|
raise RuntimeError(f"检测到循环 @import:{target}")
|
|
|
|
|
|
_imported.add(target)
|
|
|
|
|
|
if not target.exists():
|
|
|
|
|
|
raise FileNotFoundError(f"找不到要导入的 Markdown 文件:{target}")
|
|
|
|
|
|
md = target.read_text(encoding="utf-8")
|
|
|
|
|
|
md = rewrite_image_paths(md, target.parent)
|
|
|
|
|
|
md = process_imports(md, target.parent, renderers, _imported)
|
|
|
|
|
|
return md
|
|
|
|
|
|
|
|
|
|
|
|
# 数据导入
|
|
|
|
|
|
if not target.exists():
|
|
|
|
|
|
raise FileNotFoundError(f"找不到要导入的数据文件:{target}")
|
|
|
|
|
|
if renderer_name:
|
|
|
|
|
|
renderer_name = renderer_name.removesuffix(".py")
|
|
|
|
|
|
return render_with_module(renderers, renderer_name, target)
|
|
|
|
|
|
return default_data_renderer(target)
|
|
|
|
|
|
|
|
|
|
|
|
return IMPORT_RE.sub(repl, text)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------- 图片属性 {w=50%} ----------
|
|
|
|
|
|
|
|
|
|
|
|
IMAGE_ATTR_RE = re.compile(r'!\[([^\]]*)\]\(([^)]+)\)\{([^}]*)\}')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def parse_attrs(attr_str):
|
|
|
|
|
|
attrs = {}
|
|
|
|
|
|
for part in attr_str.split(","):
|
|
|
|
|
|
part = part.strip()
|
|
|
|
|
|
if "=" in part:
|
|
|
|
|
|
k, v = part.split("=", 1)
|
|
|
|
|
|
attrs[k.strip()] = v.strip()
|
|
|
|
|
|
return attrs
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def process_image_attrs(text):
|
|
|
|
|
|
"""处理 {w=50%} 图片属性语法,转换为 HTML img 标签。"""
|
|
|
|
|
|
def repl(match):
|
|
|
|
|
|
alt = match.group(1)
|
|
|
|
|
|
src = match.group(2)
|
|
|
|
|
|
attr_str = match.group(3)
|
|
|
|
|
|
attrs = parse_attrs(attr_str)
|
|
|
|
|
|
style = ""
|
|
|
|
|
|
if "w" in attrs:
|
|
|
|
|
|
style = f'width:{attrs["w"]};'
|
|
|
|
|
|
cls = attrs.get("class", "")
|
|
|
|
|
|
cls_attr = f' class="{cls}"' if cls else ""
|
|
|
|
|
|
style_attr = f' style="{style}"' if style else ""
|
|
|
|
|
|
return f'<img src="{src}" alt="{alt}"{cls_attr}{style_attr} />'
|
|
|
|
|
|
return IMAGE_ATTR_RE.sub(repl, text)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------- KaTeX 预处理 ----------
|
|
|
|
|
|
|
2026-07-28 16:31:02 +08:00
|
|
|
|
# 公式占位符存储(preprocess → postprocess 传递)
|
|
|
|
|
|
_MATH_PLACEHOLDERS = {}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-26 00:00:24 +08:00
|
|
|
|
def preprocess_katex(text):
|
|
|
|
|
|
"""
|
2026-07-28 16:31:02 +08:00
|
|
|
|
将 Markdown 中的 LaTeX 公式替换为唯一占位符,
|
2026-07-26 00:00:24 +08:00
|
|
|
|
防止 Markdown 解析器错误解释公式中的 _ * 等字符。
|
2026-07-28 16:31:02 +08:00
|
|
|
|
占位符在 postprocess_katex 中还原为 HTML 包装的公式。
|
2026-07-26 00:00:24 +08:00
|
|
|
|
"""
|
2026-07-28 16:31:02 +08:00
|
|
|
|
_MATH_PLACEHOLDERS.clear()
|
|
|
|
|
|
|
2026-07-26 00:00:24 +08:00
|
|
|
|
# 块级公式 $$...$$
|
|
|
|
|
|
def protect_display(match):
|
2026-07-28 16:31:02 +08:00
|
|
|
|
latex = match.group(1).strip()
|
|
|
|
|
|
idx = len(_MATH_PLACEHOLDERS)
|
|
|
|
|
|
key = f"\x00MATH_DISPLAY_{idx}\x00"
|
|
|
|
|
|
_MATH_PLACEHOLDERS[key] = f'<div class="math-display">$${latex}$$</div>'
|
|
|
|
|
|
return key
|
|
|
|
|
|
|
2026-07-26 00:00:24 +08:00
|
|
|
|
text = re.sub(r'\$\$\s*(.+?)\s*\$\$', protect_display, text, flags=re.DOTALL)
|
2026-07-24 20:24:39 +08:00
|
|
|
|
|
2026-07-26 00:00:24 +08:00
|
|
|
|
# 行内公式 $...$
|
|
|
|
|
|
def protect_inline(match):
|
|
|
|
|
|
latex = match.group(1)
|
2026-07-28 16:31:02 +08:00
|
|
|
|
idx = len(_MATH_PLACEHOLDERS)
|
|
|
|
|
|
key = f"\x00MATH_INLINE_{idx}\x00"
|
|
|
|
|
|
_MATH_PLACEHOLDERS[key] = f'<span class="math-inline">${latex}$</span>'
|
|
|
|
|
|
return key
|
|
|
|
|
|
|
2026-07-26 00:00:24 +08:00
|
|
|
|
text = re.sub(r'(?<!\d)\$([^$\s].*?[^$\s])\$(?!\d)', protect_inline, text)
|
2026-07-24 20:24:39 +08:00
|
|
|
|
|
2026-07-26 00:00:24 +08:00
|
|
|
|
return text
|
2026-07-24 20:24:39 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-07-28 16:31:02 +08:00
|
|
|
|
def postprocess_katex(html):
|
|
|
|
|
|
"""将占位符还原为 HTML 包装的 LaTeX 公式。"""
|
|
|
|
|
|
result = html
|
|
|
|
|
|
for key, replacement in _MATH_PLACEHOLDERS.items():
|
|
|
|
|
|
result = result.replace(key, replacement)
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-26 00:00:24 +08:00
|
|
|
|
# ---------- 自定义代码块渲染 ----------
|
2026-07-24 20:24:39 +08:00
|
|
|
|
|
2026-07-26 00:00:24 +08:00
|
|
|
|
FENCED_CODEBLOCK_OPEN_RE = re.compile(r'^```(\w+)(?:\s+[^\n]*)?$')
|
2026-07-24 20:24:39 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-07-26 00:00:24 +08:00
|
|
|
|
def render_custom_codeblocks_md(text, renderers):
|
|
|
|
|
|
"""把有对应渲染器的 fenced code block 替换为 HTML。"""
|
|
|
|
|
|
lines = text.splitlines(keepends=True)
|
|
|
|
|
|
out_lines = []
|
|
|
|
|
|
i = 0
|
|
|
|
|
|
while i < len(lines):
|
|
|
|
|
|
line = lines[i]
|
|
|
|
|
|
m = FENCED_CODEBLOCK_OPEN_RE.match(line)
|
|
|
|
|
|
if not m:
|
|
|
|
|
|
out_lines.append(line)
|
|
|
|
|
|
i += 1
|
|
|
|
|
|
continue
|
2026-07-24 20:24:39 +08:00
|
|
|
|
|
2026-07-26 00:00:24 +08:00
|
|
|
|
lang = m.group(1)
|
|
|
|
|
|
renderer_name = f"render_{lang.replace('-', '_')}"
|
|
|
|
|
|
if renderer_name not in renderers:
|
|
|
|
|
|
out_lines.append(line)
|
|
|
|
|
|
i += 1
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
func = getattr(renderers[renderer_name], "render", None)
|
|
|
|
|
|
if not callable(func):
|
|
|
|
|
|
out_lines.append(line)
|
|
|
|
|
|
i += 1
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
# 收集到闭合 fence
|
|
|
|
|
|
start = i + 1
|
|
|
|
|
|
j = start
|
|
|
|
|
|
while j < len(lines) and lines[j].strip() != '```':
|
|
|
|
|
|
j += 1
|
|
|
|
|
|
if j >= len(lines):
|
|
|
|
|
|
out_lines.append(line)
|
|
|
|
|
|
i += 1
|
|
|
|
|
|
continue
|
2026-07-24 20:24:39 +08:00
|
|
|
|
|
2026-07-26 00:00:24 +08:00
|
|
|
|
content = "".join(lines[start:j]).rstrip("\n")
|
|
|
|
|
|
rendered = func(content)
|
|
|
|
|
|
if not rendered.endswith("\n"):
|
|
|
|
|
|
rendered += "\n"
|
|
|
|
|
|
out_lines.append(rendered)
|
|
|
|
|
|
# 跳过 fence 后的换行
|
|
|
|
|
|
if j + 1 < len(lines) and lines[j + 1] == "\n":
|
|
|
|
|
|
i = j + 2
|
2026-07-24 20:24:39 +08:00
|
|
|
|
else:
|
2026-07-26 00:00:24 +08:00
|
|
|
|
i = j + 1
|
2026-07-24 20:24:39 +08:00
|
|
|
|
|
2026-07-26 00:00:24 +08:00
|
|
|
|
return "".join(out_lines)
|
2026-07-24 20:24:39 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-07-27 11:02:41 +08:00
|
|
|
|
# ---------- MPE 表格合并(^ / < / > 语法 → 展平为标准 Markdown) ----------
|
|
|
|
|
|
# ^ : 与上方单元格合并(rowspan)
|
|
|
|
|
|
# < : 与左侧单元格合并(colspan,本单元格被吸收)
|
|
|
|
|
|
# > : 与右侧单元格合并(colspan,右侧单元格被吸收)
|
2026-07-28 16:31:02 +08:00
|
|
|
|
# 策略:展平时将合并标记替换为被合并单元格的内容并附加哨兵标记,
|
|
|
|
|
|
# 再由 postprocess_table_rowspan 根据哨兵标记生成 rowspan/colspan。
|
|
|
|
|
|
# 仅显式标记了 ^ / < / > 的单元格才会参与合并,避免跨逻辑组的过度合并。
|
|
|
|
|
|
|
|
|
|
|
|
MERGE_MARKER = "<!--MPEMERGE-->"
|
2026-07-27 11:02:41 +08:00
|
|
|
|
|
|
|
|
|
|
# 表格行: | cell | cell | ... |
|
|
|
|
|
|
TABLE_ROW_RE = re.compile(r'^\|.+\|$')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def preprocess_mpe_tables(text):
|
|
|
|
|
|
"""
|
|
|
|
|
|
将 Markdown Preview Enhanced 风格的单元格合并标记展平为标准 Markdown:
|
|
|
|
|
|
^ → 替换为同列上一行的内容(纵向合并)
|
|
|
|
|
|
< → 替换为同行左侧的内容(横向合并)
|
|
|
|
|
|
> → 替换为同行右侧的内容(横向合并)
|
|
|
|
|
|
展平后交给标准 Markdown 渲染器,再由 postprocess_table_rowspan 恢复合并。
|
|
|
|
|
|
"""
|
|
|
|
|
|
lines = text.split("\n")
|
|
|
|
|
|
out = []
|
|
|
|
|
|
i = 0
|
|
|
|
|
|
|
|
|
|
|
|
while i < len(lines):
|
|
|
|
|
|
line = lines[i]
|
|
|
|
|
|
|
|
|
|
|
|
if not (TABLE_ROW_RE.match(line) and i + 1 < len(lines) and _is_separator(lines[i + 1])):
|
|
|
|
|
|
out.append(line)
|
|
|
|
|
|
i += 1
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
table_lines = [line]
|
|
|
|
|
|
i += 1
|
|
|
|
|
|
table_lines.append(lines[i])
|
|
|
|
|
|
i += 1
|
|
|
|
|
|
|
|
|
|
|
|
while i < len(lines) and TABLE_ROW_RE.match(lines[i]):
|
|
|
|
|
|
table_lines.append(lines[i])
|
|
|
|
|
|
i += 1
|
|
|
|
|
|
|
|
|
|
|
|
has_merge = any(_has_merge_cell(tl) for tl in table_lines[2:])
|
|
|
|
|
|
|
|
|
|
|
|
if has_merge:
|
|
|
|
|
|
out.extend(_flatten_mpe_table(table_lines))
|
|
|
|
|
|
else:
|
|
|
|
|
|
out.extend(table_lines)
|
|
|
|
|
|
|
|
|
|
|
|
return "\n".join(out)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _is_separator(line):
|
|
|
|
|
|
"""判断是否为表格分隔行: |---|:---|...| 或 MPE 风格 |:-:|"""
|
|
|
|
|
|
return bool(re.match(r'^\|[\s:]*-+[\s:]*\|', line))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _has_merge_cell(row_line):
|
|
|
|
|
|
"""判断表格行是否包含 MPE 合并标记(^, <, >)。"""
|
|
|
|
|
|
cells = _split_table_cells(row_line)
|
|
|
|
|
|
return any(c.strip() in ("^", "<", ">") for c in cells)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _split_table_cells(row_line):
|
|
|
|
|
|
"""将 | a | b | c | 拆分为 ['a', 'b', 'c']。"""
|
|
|
|
|
|
stripped = row_line.strip()
|
|
|
|
|
|
if stripped.startswith("|"):
|
|
|
|
|
|
stripped = stripped[1:]
|
|
|
|
|
|
if stripped.endswith("|"):
|
|
|
|
|
|
stripped = stripped[:-1]
|
|
|
|
|
|
return [c.strip() for c in stripped.split("|")]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _flatten_mpe_table(table_lines):
|
|
|
|
|
|
"""
|
|
|
|
|
|
将含 MPE 合并标记(^ < >)的表格展平为标准 Markdown。
|
|
|
|
|
|
多遍扫描:>(右→左)→ <(左→右)→ ^(上→下),
|
|
|
|
|
|
每遍将标记替换为被合并方向的内容。
|
|
|
|
|
|
展平后由 postprocess_table_rowspan 检测重复内容生成 rowspan/colspan。
|
|
|
|
|
|
"""
|
|
|
|
|
|
header = table_lines[0]
|
|
|
|
|
|
sep = table_lines[1]
|
|
|
|
|
|
data_rows = table_lines[2:]
|
|
|
|
|
|
|
|
|
|
|
|
# 解析所有数据行
|
|
|
|
|
|
parsed = [_split_table_cells(tl) for tl in data_rows]
|
|
|
|
|
|
if not parsed:
|
|
|
|
|
|
return [header, sep]
|
|
|
|
|
|
|
|
|
|
|
|
# 统一列宽(以表头为准)
|
|
|
|
|
|
num_cols = len(_split_table_cells(header))
|
|
|
|
|
|
for i, row in enumerate(parsed):
|
|
|
|
|
|
if len(row) < num_cols:
|
|
|
|
|
|
row.extend([""] * (num_cols - len(row)))
|
|
|
|
|
|
elif len(row) > num_cols:
|
|
|
|
|
|
print(f" [警告] 表格第 {i + 1} 行有 {len(row)} 列,超过表头 {num_cols} 列,多余列被忽略")
|
|
|
|
|
|
|
|
|
|
|
|
# 第 1 遍:处理 >(右→左,复制右侧单元格内容)
|
|
|
|
|
|
for row in parsed:
|
|
|
|
|
|
for col in range(num_cols - 2, -1, -1):
|
|
|
|
|
|
if row[col].strip() == ">":
|
2026-07-28 16:31:02 +08:00
|
|
|
|
row[col] = MERGE_MARKER + row[col + 1]
|
2026-07-27 11:02:41 +08:00
|
|
|
|
|
|
|
|
|
|
# 第 2 遍:处理 <(左→右,复制左侧单元格内容)
|
|
|
|
|
|
for row in parsed:
|
|
|
|
|
|
for col in range(1, num_cols):
|
|
|
|
|
|
if row[col].strip() == "<":
|
2026-07-28 16:31:02 +08:00
|
|
|
|
row[col] = MERGE_MARKER + row[col - 1]
|
2026-07-27 11:02:41 +08:00
|
|
|
|
|
2026-07-28 16:31:02 +08:00
|
|
|
|
# 第 3 遍:处理 ^(上→下,复制上方单元格内容,清除旧哨兵避免重复)
|
2026-07-27 11:02:41 +08:00
|
|
|
|
prev_cells = _split_table_cells(header)
|
|
|
|
|
|
while len(prev_cells) < num_cols:
|
|
|
|
|
|
prev_cells.append("")
|
|
|
|
|
|
for row in parsed:
|
|
|
|
|
|
for col in range(num_cols):
|
|
|
|
|
|
if row[col].strip() == "^":
|
2026-07-28 16:31:02 +08:00
|
|
|
|
content = prev_cells[col] if col < len(prev_cells) else row[col]
|
|
|
|
|
|
# 剥离 prev_cells 中已有的哨兵标记,避免链式累积
|
|
|
|
|
|
if content.startswith(MERGE_MARKER):
|
|
|
|
|
|
content = content[len(MERGE_MARKER):]
|
|
|
|
|
|
row[col] = MERGE_MARKER + content
|
2026-07-27 11:02:41 +08:00
|
|
|
|
prev_cells = list(row)
|
|
|
|
|
|
|
|
|
|
|
|
# 重建表格行
|
|
|
|
|
|
flattened = [header, sep]
|
|
|
|
|
|
for row in parsed:
|
|
|
|
|
|
flattened.append("| " + " | ".join(row[:num_cols]) + " |")
|
|
|
|
|
|
|
|
|
|
|
|
return flattened
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------- HTML 表格后处理(连续相同单元格 → rowspan / colspan) ----------
|
|
|
|
|
|
# 依赖 Python markdown "tables" 扩展生成 <tbody> 包裹数据行。
|
|
|
|
|
|
|
|
|
|
|
|
TD_RE = re.compile(r'<td([^>]*)>(.*?)</td>', re.DOTALL)
|
|
|
|
|
|
TR_RE = re.compile(r'<tr>(.*?)</tr>', re.DOTALL)
|
|
|
|
|
|
TBODY_RE = re.compile(r'(<tbody>.*?</tbody>)', re.DOTALL)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def postprocess_table_rowspan(html):
|
|
|
|
|
|
"""
|
2026-07-28 16:31:02 +08:00
|
|
|
|
扫描 HTML 表格的 <tbody>,将带有 MERGE_MARKER 哨兵的单元格合并:
|
|
|
|
|
|
- 同一列上下连续相同(且下方含哨兵) → rowspan
|
|
|
|
|
|
- 同一行左右连续相同(且右侧含哨兵) → colspan
|
|
|
|
|
|
与 preprocess_mpe_tables 配合:仅显式标记了 ^ / < / > 的单元格生成哨兵,
|
|
|
|
|
|
避免跨逻辑组的过度合并。
|
2026-07-27 11:02:41 +08:00
|
|
|
|
|
2026-07-28 12:34:12 +08:00
|
|
|
|
带有 class="no-rowspan" 的 <table> 会被跳过,不进行合并处理。
|
2026-07-27 11:02:41 +08:00
|
|
|
|
注意:仅处理 <tbody> 内的 <tr>(Python markdown tables 扩展的输出格式)。
|
|
|
|
|
|
"""
|
2026-07-28 12:34:12 +08:00
|
|
|
|
# 匹配整个 <table> 元素,检查其 class 属性
|
|
|
|
|
|
TABLE_RE = re.compile(r'(<table[^>]*>)(.*?)(</table>)', re.DOTALL)
|
|
|
|
|
|
|
2026-07-27 11:02:41 +08:00
|
|
|
|
def merge_tbody(match):
|
|
|
|
|
|
tbody = match.group(1)
|
|
|
|
|
|
rows = TR_RE.findall(tbody)
|
|
|
|
|
|
if len(rows) < 2:
|
|
|
|
|
|
return tbody
|
|
|
|
|
|
|
2026-07-28 12:34:12 +08:00
|
|
|
|
# 解析所有单元格(统一补齐到最大列数,处理 rowspan 导致的列数不一致)
|
2026-07-27 11:02:41 +08:00
|
|
|
|
row_cells = []
|
|
|
|
|
|
for row_html in rows:
|
|
|
|
|
|
cells = []
|
|
|
|
|
|
for m in TD_RE.finditer(row_html):
|
|
|
|
|
|
cells.append({"attrs": m.group(1).strip(), "text": m.group(2).strip()})
|
|
|
|
|
|
row_cells.append(cells)
|
|
|
|
|
|
|
|
|
|
|
|
if not row_cells:
|
|
|
|
|
|
return tbody
|
|
|
|
|
|
|
|
|
|
|
|
num_cols = max(len(rc) for rc in row_cells) if row_cells else 0
|
|
|
|
|
|
num_rows = len(row_cells)
|
|
|
|
|
|
if num_cols == 0:
|
|
|
|
|
|
return tbody
|
|
|
|
|
|
|
2026-07-28 12:34:12 +08:00
|
|
|
|
# 统一补齐:不足 num_cols 的行用空单元格补齐
|
|
|
|
|
|
for row in row_cells:
|
|
|
|
|
|
while len(row) < num_cols:
|
|
|
|
|
|
row.append({"attrs": "", "text": ""})
|
|
|
|
|
|
|
2026-07-27 11:02:41 +08:00
|
|
|
|
# covered[r][c]:该单元格已被 rowspan 或 colspan 覆盖,渲染时跳过
|
|
|
|
|
|
covered = [[False] * num_cols for _ in range(num_rows)]
|
|
|
|
|
|
rowspan = [[1] * num_cols for _ in range(num_rows)]
|
|
|
|
|
|
colspan = [[1] * num_cols for _ in range(num_rows)]
|
|
|
|
|
|
|
|
|
|
|
|
# ---- 计算 rowspan(逐列扫描) ----
|
2026-07-28 16:31:02 +08:00
|
|
|
|
# 仅当下方单元格带 MERGE_MARKER 哨兵且内容匹配时才合并
|
2026-07-27 11:02:41 +08:00
|
|
|
|
for col in range(num_cols):
|
|
|
|
|
|
row = 0
|
|
|
|
|
|
while row < num_rows:
|
|
|
|
|
|
count = 1
|
|
|
|
|
|
r = row + 1
|
|
|
|
|
|
while r < num_rows:
|
2026-07-28 16:31:02 +08:00
|
|
|
|
below_text = row_cells[r][col]["text"]
|
|
|
|
|
|
above_text = row_cells[row][col]["text"]
|
|
|
|
|
|
# 下方单元格必须带哨兵标记,且去除哨兵后与上方内容一致
|
|
|
|
|
|
if (below_text.startswith(MERGE_MARKER)
|
|
|
|
|
|
and below_text[len(MERGE_MARKER):] == above_text
|
|
|
|
|
|
and above_text != ""):
|
2026-07-27 11:02:41 +08:00
|
|
|
|
count += 1
|
|
|
|
|
|
covered[r][col] = True
|
2026-07-28 16:31:02 +08:00
|
|
|
|
# 将合并单元格的内容统一为去除哨兵的版本
|
|
|
|
|
|
row_cells[r][col]["text"] = above_text
|
2026-07-27 11:02:41 +08:00
|
|
|
|
r += 1
|
|
|
|
|
|
else:
|
|
|
|
|
|
break
|
|
|
|
|
|
if count > 1:
|
|
|
|
|
|
rowspan[row][col] = count
|
2026-07-28 12:34:12 +08:00
|
|
|
|
row = r
|
2026-07-27 11:02:41 +08:00
|
|
|
|
|
|
|
|
|
|
# ---- 计算 colspan(逐行扫描,跳过已覆盖单元格) ----
|
|
|
|
|
|
for row in range(num_rows):
|
|
|
|
|
|
col = 0
|
2026-07-28 12:34:12 +08:00
|
|
|
|
while col < num_cols:
|
2026-07-27 11:02:41 +08:00
|
|
|
|
if covered[row][col]:
|
|
|
|
|
|
col += 1
|
|
|
|
|
|
continue
|
|
|
|
|
|
count = 1
|
|
|
|
|
|
c = col + 1
|
2026-07-28 12:34:12 +08:00
|
|
|
|
while c < num_cols:
|
2026-07-28 16:31:02 +08:00
|
|
|
|
right_text = row_cells[row][c]["text"]
|
|
|
|
|
|
left_text = row_cells[row][col]["text"]
|
|
|
|
|
|
# 右侧单元格必须带哨兵标记,且去除哨兵后与左侧内容一致
|
2026-07-27 11:02:41 +08:00
|
|
|
|
if (not covered[row][c]
|
2026-07-28 16:31:02 +08:00
|
|
|
|
and right_text.startswith(MERGE_MARKER)
|
|
|
|
|
|
and right_text[len(MERGE_MARKER):] == left_text
|
|
|
|
|
|
and left_text != ""):
|
2026-07-27 11:02:41 +08:00
|
|
|
|
count += 1
|
|
|
|
|
|
covered[row][c] = True
|
2026-07-28 16:31:02 +08:00
|
|
|
|
row_cells[row][c]["text"] = left_text
|
2026-07-27 11:02:41 +08:00
|
|
|
|
c += 1
|
|
|
|
|
|
else:
|
|
|
|
|
|
break
|
|
|
|
|
|
if count > 1:
|
|
|
|
|
|
colspan[row][col] = count
|
2026-07-28 12:34:12 +08:00
|
|
|
|
col = c
|
2026-07-27 11:02:41 +08:00
|
|
|
|
|
|
|
|
|
|
# ---- 生成带 rowspan / colspan 的 HTML ----
|
|
|
|
|
|
new_rows = []
|
|
|
|
|
|
for row in range(num_rows):
|
|
|
|
|
|
new_cells = []
|
|
|
|
|
|
for col in range(num_cols):
|
2026-07-28 12:34:12 +08:00
|
|
|
|
if covered[row][col]:
|
2026-07-27 11:02:41 +08:00
|
|
|
|
continue
|
|
|
|
|
|
cell = row_cells[row][col]
|
|
|
|
|
|
rs = rowspan[row][col]
|
|
|
|
|
|
cs = colspan[row][col]
|
|
|
|
|
|
attrs = cell["attrs"]
|
|
|
|
|
|
if rs > 1:
|
|
|
|
|
|
attrs += f' rowspan="{rs}"'
|
|
|
|
|
|
if cs > 1:
|
|
|
|
|
|
attrs += f' colspan="{cs}"'
|
2026-07-28 12:34:12 +08:00
|
|
|
|
# 修复: attrs 非空时加前导空格,避免 <tdrowspan="..."> 畸形标签
|
|
|
|
|
|
if attrs:
|
|
|
|
|
|
new_cells.append(f'<td {attrs}>{cell["text"]}</td>')
|
|
|
|
|
|
else:
|
|
|
|
|
|
new_cells.append(f'<td>{cell["text"]}</td>')
|
2026-07-27 11:02:41 +08:00
|
|
|
|
new_rows.append("<tr>" + "".join(new_cells) + "</tr>")
|
|
|
|
|
|
|
|
|
|
|
|
return "<tbody>" + "".join(new_rows) + "</tbody>"
|
|
|
|
|
|
|
2026-07-28 12:34:12 +08:00
|
|
|
|
def process_table(match):
|
|
|
|
|
|
table_open = match.group(1)
|
|
|
|
|
|
body = match.group(2)
|
|
|
|
|
|
table_close = match.group(3)
|
|
|
|
|
|
# 跳过带有 no-rowspan class 的表格
|
|
|
|
|
|
if 'no-rowspan' in table_open:
|
|
|
|
|
|
return match.group(0)
|
|
|
|
|
|
# 处理表格内的 <tbody>
|
|
|
|
|
|
body = TBODY_RE.sub(merge_tbody, body)
|
|
|
|
|
|
return table_open + body + table_close
|
|
|
|
|
|
|
|
|
|
|
|
return TABLE_RE.sub(process_table, html)
|
2026-07-27 11:02:41 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-07-26 00:00:24 +08:00
|
|
|
|
# ---------- Markdown → HTML ----------
|
2026-07-24 20:24:39 +08:00
|
|
|
|
|
2026-07-26 00:00:24 +08:00
|
|
|
|
def markdown_to_html(text):
|
|
|
|
|
|
md = markdown.Markdown(extensions=[
|
|
|
|
|
|
"extra",
|
|
|
|
|
|
"tables",
|
|
|
|
|
|
"fenced_code",
|
|
|
|
|
|
"toc",
|
|
|
|
|
|
])
|
|
|
|
|
|
return md.convert(text)
|
2026-07-24 20:24:39 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-07-26 00:00:24 +08:00
|
|
|
|
# ---------- 图片 base64 内嵌(HTML 级别) ----------
|
2026-07-24 20:24:39 +08:00
|
|
|
|
|
2026-07-26 00:00:24 +08:00
|
|
|
|
IMG_SRC_RE = re.compile(r'<img([^>]*?)src=["\']([^"\']+)["\']([^>]*)>', re.IGNORECASE)
|
2026-07-24 20:24:39 +08:00
|
|
|
|
|
2026-07-26 00:00:24 +08:00
|
|
|
|
MIME_TABLE = {
|
|
|
|
|
|
".png": "image/png",
|
|
|
|
|
|
".jpg": "image/jpeg",
|
|
|
|
|
|
".jpeg": "image/jpeg",
|
|
|
|
|
|
".gif": "image/gif",
|
|
|
|
|
|
".svg": "image/svg+xml",
|
|
|
|
|
|
".webp": "image/webp",
|
|
|
|
|
|
}
|
2026-07-24 20:24:39 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-07-26 00:00:24 +08:00
|
|
|
|
def guess_mime(ext):
|
|
|
|
|
|
return MIME_TABLE.get(ext.lower(), "application/octet-stream")
|
2026-07-24 20:24:39 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-07-26 00:00:24 +08:00
|
|
|
|
def embed_images(html, base_dirs):
|
|
|
|
|
|
"""扫描 HTML 中的 img 标签,将本地图片替换为 base64 data URI。"""
|
|
|
|
|
|
def resolve_src(src):
|
|
|
|
|
|
if Path(src).is_absolute():
|
|
|
|
|
|
path = Path(src)
|
|
|
|
|
|
if path.exists():
|
|
|
|
|
|
return path
|
|
|
|
|
|
return None
|
|
|
|
|
|
for base in base_dirs:
|
|
|
|
|
|
path = base / src
|
|
|
|
|
|
if path.exists():
|
|
|
|
|
|
return path
|
|
|
|
|
|
return None
|
2026-07-24 20:24:39 +08:00
|
|
|
|
|
2026-07-26 00:00:24 +08:00
|
|
|
|
def repl(match):
|
|
|
|
|
|
prefix = match.group(1)
|
|
|
|
|
|
src = match.group(2)
|
|
|
|
|
|
suffix = match.group(3)
|
2026-07-24 20:24:39 +08:00
|
|
|
|
|
2026-07-26 00:00:24 +08:00
|
|
|
|
if src.startswith(("http://", "https://", "data:")):
|
|
|
|
|
|
return match.group(0)
|
|
|
|
|
|
|
|
|
|
|
|
path = resolve_src(src)
|
|
|
|
|
|
if path is None:
|
|
|
|
|
|
print(f" [警告] 找不到图片,保留原路径:{src}")
|
|
|
|
|
|
return match.group(0)
|
|
|
|
|
|
try:
|
|
|
|
|
|
mime = guess_mime(path.suffix)
|
|
|
|
|
|
data = path.read_bytes()
|
|
|
|
|
|
b64 = base64.b64encode(data).decode("ascii")
|
|
|
|
|
|
return f'<img{prefix}src="data:{mime};base64,{b64}"{suffix}>'
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
print(f" [警告] 图片 base64 编码失败({src}):{e}")
|
|
|
|
|
|
return match.group(0)
|
|
|
|
|
|
|
|
|
|
|
|
return IMG_SRC_RE.sub(repl, html)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------- 目录与锚点 ----------
|
2026-07-24 20:24:39 +08:00
|
|
|
|
|
2026-07-26 00:00:24 +08:00
|
|
|
|
def slugify(text):
|
|
|
|
|
|
"""生成 HTML 锚点 ID。"""
|
|
|
|
|
|
anchor = re.sub(r'[^\w\s一-鿿-]', '', text)
|
|
|
|
|
|
return anchor.strip().replace(" ", "-")[:50]
|
2026-07-24 20:24:39 +08:00
|
|
|
|
|
2026-07-26 00:00:24 +08:00
|
|
|
|
|
|
|
|
|
|
def generate_toc(html):
|
2026-07-28 12:34:12 +08:00
|
|
|
|
"""从 HTML 中提取 H1-H4 标题,生成目录。
|
|
|
|
|
|
锚点直接从标题已有的 id 属性读取,确保与 HTML 中的实际 ID 一致。"""
|
2026-07-26 00:00:24 +08:00
|
|
|
|
toc = []
|
2026-07-28 12:34:12 +08:00
|
|
|
|
# 匹配带 id 属性的标题标签
|
|
|
|
|
|
for m in re.finditer(r'<h([1-4])\s+[^>]*\bid="([^"]*)"[^>]*>(.*?)</h\1>', html, re.DOTALL):
|
2026-07-26 00:00:24 +08:00
|
|
|
|
level = int(m.group(1))
|
2026-07-28 12:34:12 +08:00
|
|
|
|
anchor = m.group(2)
|
|
|
|
|
|
text = re.sub(r'<.*?>', '', m.group(3)).strip()
|
|
|
|
|
|
toc.append({"level": level, "text": text, "anchor": anchor})
|
2026-07-26 00:00:24 +08:00
|
|
|
|
return toc
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def inject_anchors(html):
|
2026-07-28 12:34:12 +08:00
|
|
|
|
"""为所有 H1-H4 标签注入 id 属性,使侧边栏目录可跳转。
|
|
|
|
|
|
如果标题已有 id 属性(如自定义渲染器预设的锚点),则保留原 id。"""
|
2026-07-26 00:00:24 +08:00
|
|
|
|
def repl(match):
|
|
|
|
|
|
level = match.group(1)
|
|
|
|
|
|
attrs = match.group(2)
|
|
|
|
|
|
inner = match.group(3)
|
2026-07-28 12:34:12 +08:00
|
|
|
|
# 保留已有的 id 属性(例如渲染器手动设置的锚点)
|
|
|
|
|
|
if re.search(r'\bid\s*=', attrs):
|
|
|
|
|
|
return f'<h{level}{attrs}>{inner}</h{level}>'
|
2026-07-26 00:00:24 +08:00
|
|
|
|
anchor = slugify(re.sub(r'<.*?>', '', inner).strip())
|
|
|
|
|
|
return f'<h{level} id="{anchor}"{attrs}>{inner}</h{level}>'
|
|
|
|
|
|
return re.sub(
|
|
|
|
|
|
r'<h([1-4])([^>]*)>(.*?)</h\1>',
|
|
|
|
|
|
repl,
|
|
|
|
|
|
html,
|
|
|
|
|
|
flags=re.DOTALL,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------- Logo 处理 ----------
|
|
|
|
|
|
|
|
|
|
|
|
def find_logo(logo_path_str):
|
|
|
|
|
|
"""定位 logo 文件;需在 project.yaml 明确指定 logo 字段。"""
|
|
|
|
|
|
if not logo_path_str:
|
|
|
|
|
|
return None
|
|
|
|
|
|
path = Path(logo_path_str)
|
|
|
|
|
|
if path.is_absolute():
|
|
|
|
|
|
return path
|
|
|
|
|
|
return PROJECT_ROOT / path
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def embed_logo(logo_path, max_width=400):
|
|
|
|
|
|
"""压缩 logo 并返回 base64 data URI。"""
|
|
|
|
|
|
if logo_path is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
from PIL import Image
|
|
|
|
|
|
except ImportError:
|
|
|
|
|
|
print(" 提示: 未安装 Pillow,跳过 logo 处理")
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
img = Image.open(logo_path)
|
|
|
|
|
|
w, h = img.size
|
|
|
|
|
|
if w > max_width:
|
|
|
|
|
|
ratio = max_width / w
|
|
|
|
|
|
img = img.resize((max_width, int(h * ratio)), Image.Resampling.LANCZOS)
|
|
|
|
|
|
|
|
|
|
|
|
ext = logo_path.suffix.lower()
|
|
|
|
|
|
if ext == ".svg":
|
|
|
|
|
|
data = logo_path.read_bytes()
|
|
|
|
|
|
b64 = base64.b64encode(data).decode("ascii")
|
|
|
|
|
|
return f"data:image/svg+xml;base64,{b64}"
|
|
|
|
|
|
|
|
|
|
|
|
buf = io.BytesIO()
|
|
|
|
|
|
if img.mode in ("RGBA", "P"):
|
|
|
|
|
|
img.save(buf, format="PNG", optimize=True)
|
|
|
|
|
|
mime = "image/png"
|
|
|
|
|
|
else:
|
|
|
|
|
|
img = img.convert("RGB")
|
|
|
|
|
|
img.save(buf, format="JPEG", optimize=True, quality=90)
|
|
|
|
|
|
mime = "image/jpeg"
|
|
|
|
|
|
b64 = base64.b64encode(buf.getvalue()).decode("ascii")
|
|
|
|
|
|
return f"data:{mime};base64,{b64}"
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
print(f" 警告: logo 处理失败: {e}")
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------- 模板渲染 ----------
|
|
|
|
|
|
|
|
|
|
|
|
def inline_css():
|
|
|
|
|
|
"""读取主题 CSS。"""
|
|
|
|
|
|
css_path = THEMES_DIR / "report.css"
|
|
|
|
|
|
if css_path.exists():
|
|
|
|
|
|
return css_path.read_text(encoding="utf-8")
|
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def render_template(config, content, toc, logo_data_uri=None):
|
|
|
|
|
|
env = Environment(
|
|
|
|
|
|
loader=FileSystemLoader(TEMPLATES_DIR),
|
|
|
|
|
|
autoescape=select_autoescape(["html", "xml"]),
|
|
|
|
|
|
)
|
|
|
|
|
|
template = env.get_template("report.html")
|
|
|
|
|
|
css = inline_css()
|
2026-07-24 20:24:39 +08:00
|
|
|
|
return template.render(
|
2026-07-26 00:00:24 +08:00
|
|
|
|
title=config["title"],
|
|
|
|
|
|
subtitle=config["subtitle"],
|
|
|
|
|
|
author=config["author"],
|
|
|
|
|
|
version=config["version"],
|
|
|
|
|
|
doc_type=config["doc_type"],
|
|
|
|
|
|
logo=logo_data_uri,
|
|
|
|
|
|
date=config["date"],
|
|
|
|
|
|
content=content,
|
2026-07-24 20:24:39 +08:00
|
|
|
|
toc=toc,
|
2026-07-26 00:00:24 +08:00
|
|
|
|
css=css,
|
2026-07-24 20:24:39 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-26 00:00:24 +08:00
|
|
|
|
# ---------- 检查脚本 ----------
|
|
|
|
|
|
|
|
|
|
|
|
def run_checks(checks):
|
|
|
|
|
|
issues = []
|
|
|
|
|
|
for name, module in checks.items():
|
|
|
|
|
|
func = getattr(module, "check", None)
|
|
|
|
|
|
if not callable(func):
|
|
|
|
|
|
continue
|
|
|
|
|
|
try:
|
|
|
|
|
|
result = func(str(PROJECT_ROOT))
|
|
|
|
|
|
if result:
|
|
|
|
|
|
issues.extend(result)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
print(f" 警告: 检查脚本 {name} 执行失败: {e}")
|
|
|
|
|
|
if issues:
|
|
|
|
|
|
print("检查发现问题:")
|
|
|
|
|
|
for item in issues:
|
|
|
|
|
|
print(f" - {item}")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-24 20:24:39 +08:00
|
|
|
|
# ---------- 主入口 ----------
|
|
|
|
|
|
|
|
|
|
|
|
def main():
|
2026-07-26 00:00:24 +08:00
|
|
|
|
print("=== Docs as Code 构建 ===")
|
2026-07-24 20:24:39 +08:00
|
|
|
|
print()
|
|
|
|
|
|
|
2026-07-26 00:00:24 +08:00
|
|
|
|
# 0. 加载配置
|
|
|
|
|
|
config = load_config()
|
|
|
|
|
|
print(f"[配置] {config['title']} — {config['version']}")
|
2026-07-24 20:24:39 +08:00
|
|
|
|
print(f" 章节数: {len(config.get('chapters', []))}")
|
|
|
|
|
|
|
2026-07-26 00:00:24 +08:00
|
|
|
|
# 0a. 加载插件
|
|
|
|
|
|
print("[插件] 加载扩展模块 ...")
|
|
|
|
|
|
renderers = load_plugin_modules(RENDERERS_DIR)
|
|
|
|
|
|
checks = load_plugin_modules(CHECKS_DIR)
|
2026-07-24 20:24:39 +08:00
|
|
|
|
|
2026-07-26 00:00:24 +08:00
|
|
|
|
# 0b. 运行检查
|
|
|
|
|
|
if config.get("checks", True) and checks:
|
|
|
|
|
|
print("[检查] 运行检查脚本 ...")
|
|
|
|
|
|
run_checks(checks)
|
|
|
|
|
|
|
|
|
|
|
|
# 1. 逐章节处理
|
|
|
|
|
|
chapters = config.get("chapters", [])
|
|
|
|
|
|
if not chapters:
|
|
|
|
|
|
sys.exit("错误: project.yaml 中未定义 chapters 列表")
|
2026-07-24 20:24:39 +08:00
|
|
|
|
|
2026-07-26 00:00:24 +08:00
|
|
|
|
html_parts = []
|
|
|
|
|
|
for ch_file in chapters:
|
|
|
|
|
|
ch_path = CHAPTERS_DIR / ch_file
|
|
|
|
|
|
if not ch_path.exists():
|
|
|
|
|
|
print(f" 警告: 章节文件不存在,跳过: {ch_file}")
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
text = ch_path.read_text(encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
|
|
# @import 处理
|
|
|
|
|
|
text = process_imports(text, ch_path.parent, renderers)
|
|
|
|
|
|
|
|
|
|
|
|
# 图片属性处理
|
|
|
|
|
|
text = process_image_attrs(text)
|
|
|
|
|
|
|
2026-07-27 11:02:41 +08:00
|
|
|
|
# MPE 表格合并预处理(^ → rowspan)
|
|
|
|
|
|
text = preprocess_mpe_tables(text)
|
|
|
|
|
|
|
2026-07-26 00:00:24 +08:00
|
|
|
|
# KaTeX 预处理
|
|
|
|
|
|
text = preprocess_katex(text)
|
|
|
|
|
|
|
|
|
|
|
|
# 自定义代码块渲染
|
|
|
|
|
|
text = render_custom_codeblocks_md(text, renderers)
|
|
|
|
|
|
|
|
|
|
|
|
# Markdown → HTML
|
|
|
|
|
|
chapter_html = markdown_to_html(text)
|
|
|
|
|
|
|
2026-07-28 16:31:02 +08:00
|
|
|
|
# KaTeX 还原(占位符 → HTML 包装的公式)
|
|
|
|
|
|
chapter_html = postprocess_katex(chapter_html)
|
|
|
|
|
|
|
2026-07-26 00:00:24 +08:00
|
|
|
|
# 图片 base64 内嵌
|
|
|
|
|
|
base_dirs = [ch_path.parent, ASSETS_DIR, PROJECT_ROOT]
|
|
|
|
|
|
chapter_html = embed_images(chapter_html, base_dirs)
|
|
|
|
|
|
|
|
|
|
|
|
html_parts.append(chapter_html)
|
|
|
|
|
|
|
|
|
|
|
|
full_html = "\n\n".join(html_parts)
|
|
|
|
|
|
print(f" HTML 总字符数: {len(full_html)}")
|
|
|
|
|
|
|
|
|
|
|
|
# 2. 注入锚点 + 提取目录
|
|
|
|
|
|
full_html = inject_anchors(full_html)
|
2026-07-27 11:02:41 +08:00
|
|
|
|
full_html = postprocess_table_rowspan(full_html)
|
2026-07-26 00:00:24 +08:00
|
|
|
|
toc = generate_toc(full_html)
|
|
|
|
|
|
print(f" 目录条目数: {len(toc)}")
|
|
|
|
|
|
|
|
|
|
|
|
# 3. Logo 处理
|
|
|
|
|
|
logo_path = find_logo(config.get("logo", ""))
|
|
|
|
|
|
logo_data_uri = embed_logo(logo_path) if logo_path else None
|
|
|
|
|
|
if logo_data_uri:
|
|
|
|
|
|
print(" logo: 已内嵌")
|
|
|
|
|
|
else:
|
|
|
|
|
|
print(" logo: 未配置,封面将不显示 logo")
|
|
|
|
|
|
|
|
|
|
|
|
# 4. 模板渲染 + 输出
|
|
|
|
|
|
output_filename = re.sub(r'[^\w\-.]', '_', config["title"]) + ".html"
|
2026-07-24 20:24:39 +08:00
|
|
|
|
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
2026-07-26 00:00:24 +08:00
|
|
|
|
output_path = OUTPUT_DIR / output_filename
|
2026-07-24 20:24:39 +08:00
|
|
|
|
|
2026-07-26 00:00:24 +08:00
|
|
|
|
final_html = render_template(config, full_html, toc, logo_data_uri)
|
|
|
|
|
|
output_path.write_text(final_html, encoding="utf-8")
|
|
|
|
|
|
print(f"\n构建完成:{output_path}")
|
|
|
|
|
|
print(f"文件大小: {output_path.stat().st_size:,} 字节")
|
2026-07-24 20:24:39 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
|
main()
|