readout_program/doc_builder/build.py

576 lines
17 KiB
Python
Raw Normal View History

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)
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}")
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'![{alt}]({abs_path})'
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):
"""处理 ![](path){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 预处理 ----------
def preprocess_katex(text):
"""
Markdown 中的 LaTeX 公式包装为原始 HTML
防止 Markdown 解析器错误解释公式中的 _ * 等字符
"""
# 块级公式 $$...$$
def protect_display(match):
latex = match.group(1)
return f'<div class="math-display">$${latex}$$</div>'
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)
return f'<span class="math-inline">${latex}$</span>'
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-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-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):
"""从 HTML 中提取 H1-H4 标题,生成目录。"""
toc = []
for m in re.finditer(r'<h([1-4])[^>]*>(.*?)</h\1>', html, re.DOTALL):
level = int(m.group(1))
text = re.sub(r'<.*?>', '', m.group(2)).strip()
toc.append({"level": level, "text": text, "anchor": slugify(text)})
return toc
def inject_anchors(html):
"""为所有 H1-H4 标签注入 id 属性,使侧边栏目录可跳转。"""
def repl(match):
level = match.group(1)
attrs = match.group(2)
inner = match.group(3)
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)
# KaTeX 预处理
text = preprocess_katex(text)
# 自定义代码块渲染
text = render_custom_codeblocks_md(text, renderers)
# Markdown → HTML
chapter_html = markdown_to_html(text)
# 图片 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)
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()