893 lines
29 KiB
Python
893 lines
29 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Docs as Code — 构建入口
|
||
|
||
读取 project.yaml → 逐章节处理 @import → Markdown → HTML → 图片内嵌
|
||
→ 组装 → 注入锚点 → 提取目录 → 模板渲染 → 自包含 HTML 报告。
|
||
|
||
用法:
|
||
python build.py
|
||
|
||
输出:
|
||
output/<标题>.html (自包含 HTML,可离线分发)
|
||
"""
|
||
|
||
import base64
|
||
import csv
|
||
import importlib.util
|
||
import io
|
||
import json
|
||
import re
|
||
import sys
|
||
from datetime import date
|
||
from pathlib import Path
|
||
|
||
import markdown
|
||
import yaml
|
||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||
|
||
# ---------- 路径配置 ----------
|
||
PROJECT_ROOT = Path(__file__).resolve().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
|
||
|
||
|
||
# ---------- 插件发现 ----------
|
||
|
||
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)
|
||
# 注册到 sys.modules 以支持插件间交叉导入
|
||
sys.modules[py_file.stem] = mod
|
||
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}")
|
||
# 加载失败时清理 sys.modules
|
||
sys.modules.pop(py_file.stem, None)
|
||
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 预处理 ----------
|
||
|
||
# 公式占位符存储(preprocess → postprocess 传递)
|
||
_MATH_PLACEHOLDERS = {}
|
||
|
||
|
||
def preprocess_katex(text):
|
||
"""
|
||
将 Markdown 中的 LaTeX 公式替换为唯一占位符,
|
||
防止 Markdown 解析器错误解释公式中的 _ * 等字符。
|
||
占位符在 postprocess_katex 中还原为 HTML 包装的公式。
|
||
"""
|
||
_MATH_PLACEHOLDERS.clear()
|
||
|
||
# 块级公式 $$...$$
|
||
def protect_display(match):
|
||
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
|
||
|
||
text = re.sub(r'\$\$\s*(.+?)\s*\$\$', protect_display, text, flags=re.DOTALL)
|
||
|
||
# 行内公式 $...$
|
||
def protect_inline(match):
|
||
latex = match.group(1)
|
||
idx = len(_MATH_PLACEHOLDERS)
|
||
key = f"\x00MATH_INLINE_{idx}\x00"
|
||
_MATH_PLACEHOLDERS[key] = f'<span class="math-inline">${latex}$</span>'
|
||
return key
|
||
|
||
text = re.sub(r'(?<!\d)\$([^$\s].*?[^$\s])\$(?!\d)', protect_inline, text)
|
||
|
||
return text
|
||
|
||
|
||
def postprocess_katex(html):
|
||
"""将占位符还原为 HTML 包装的 LaTeX 公式。"""
|
||
result = html
|
||
for key, replacement in _MATH_PLACEHOLDERS.items():
|
||
result = result.replace(key, replacement)
|
||
return result
|
||
|
||
|
||
# ---------- 自定义代码块渲染 ----------
|
||
|
||
FENCED_CODEBLOCK_OPEN_RE = re.compile(r'^```(\w+)(?:\s+[^\n]*)?$')
|
||
|
||
|
||
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
|
||
|
||
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
|
||
|
||
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
|
||
else:
|
||
i = j + 1
|
||
|
||
return "".join(out_lines)
|
||
|
||
|
||
# ---------- MPE 表格合并(^ / < / > 语法 → 展平为标准 Markdown) ----------
|
||
# ^ : 与上方单元格合并(rowspan)
|
||
# < : 与左侧单元格合并(colspan,本单元格被吸收)
|
||
# > : 与右侧单元格合并(colspan,右侧单元格被吸收)
|
||
# 策略:展平时将合并标记替换为被合并单元格的内容并附加哨兵标记,
|
||
# 再由 postprocess_table_rowspan 根据哨兵标记生成 rowspan/colspan。
|
||
# 仅显式标记了 ^ / < / > 的单元格才会参与合并,避免跨逻辑组的过度合并。
|
||
|
||
MERGE_MARKER = "<!--MPEMERGE-->"
|
||
|
||
# 表格行: | 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() == ">":
|
||
row[col] = MERGE_MARKER + row[col + 1]
|
||
|
||
# 第 2 遍:处理 <(左→右,复制左侧单元格内容)
|
||
for row in parsed:
|
||
for col in range(1, num_cols):
|
||
if row[col].strip() == "<":
|
||
row[col] = MERGE_MARKER + row[col - 1]
|
||
|
||
# 第 3 遍:处理 ^(上→下,复制上方单元格内容,清除旧哨兵避免重复)
|
||
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() == "^":
|
||
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
|
||
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):
|
||
"""
|
||
扫描 HTML 表格的 <tbody>,将带有 MERGE_MARKER 哨兵的单元格合并:
|
||
- 同一列上下连续相同(且下方含哨兵) → rowspan
|
||
- 同一行左右连续相同(且右侧含哨兵) → colspan
|
||
与 preprocess_mpe_tables 配合:仅显式标记了 ^ / < / > 的单元格生成哨兵,
|
||
避免跨逻辑组的过度合并。
|
||
|
||
带有 class="no-rowspan" 的 <table> 会被跳过,不进行合并处理。
|
||
注意:仅处理 <tbody> 内的 <tr>(Python markdown tables 扩展的输出格式)。
|
||
"""
|
||
# 匹配整个 <table> 元素,检查其 class 属性
|
||
TABLE_RE = re.compile(r'(<table[^>]*>)(.*?)(</table>)', re.DOTALL)
|
||
|
||
def merge_tbody(match):
|
||
tbody = match.group(1)
|
||
rows = TR_RE.findall(tbody)
|
||
if len(rows) < 2:
|
||
return tbody
|
||
|
||
# 解析所有单元格(统一补齐到最大列数,处理 rowspan 导致的列数不一致)
|
||
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
|
||
|
||
# 统一补齐:不足 num_cols 的行用空单元格补齐
|
||
for row in row_cells:
|
||
while len(row) < num_cols:
|
||
row.append({"attrs": "", "text": ""})
|
||
|
||
# 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(逐列扫描) ----
|
||
# 仅当下方单元格带 MERGE_MARKER 哨兵且内容匹配时才合并
|
||
for col in range(num_cols):
|
||
row = 0
|
||
while row < num_rows:
|
||
count = 1
|
||
r = row + 1
|
||
while r < num_rows:
|
||
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 != ""):
|
||
count += 1
|
||
covered[r][col] = True
|
||
# 将合并单元格的内容统一为去除哨兵的版本
|
||
row_cells[r][col]["text"] = above_text
|
||
r += 1
|
||
else:
|
||
break
|
||
if count > 1:
|
||
rowspan[row][col] = count
|
||
row = r
|
||
|
||
# ---- 计算 colspan(逐行扫描,跳过已覆盖单元格) ----
|
||
for row in range(num_rows):
|
||
col = 0
|
||
while col < num_cols:
|
||
if covered[row][col]:
|
||
col += 1
|
||
continue
|
||
count = 1
|
||
c = col + 1
|
||
while c < num_cols:
|
||
right_text = row_cells[row][c]["text"]
|
||
left_text = row_cells[row][col]["text"]
|
||
# 右侧单元格必须带哨兵标记,且去除哨兵后与左侧内容一致
|
||
if (not covered[row][c]
|
||
and right_text.startswith(MERGE_MARKER)
|
||
and right_text[len(MERGE_MARKER):] == left_text
|
||
and left_text != ""):
|
||
count += 1
|
||
covered[row][c] = True
|
||
row_cells[row][c]["text"] = left_text
|
||
c += 1
|
||
else:
|
||
break
|
||
if count > 1:
|
||
colspan[row][col] = count
|
||
col = c
|
||
|
||
# ---- 生成带 rowspan / colspan 的 HTML ----
|
||
new_rows = []
|
||
for row in range(num_rows):
|
||
new_cells = []
|
||
for col in range(num_cols):
|
||
if covered[row][col]:
|
||
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}"'
|
||
# 修复: attrs 非空时加前导空格,避免 <tdrowspan="..."> 畸形标签
|
||
if attrs:
|
||
new_cells.append(f'<td {attrs}>{cell["text"]}</td>')
|
||
else:
|
||
new_cells.append(f'<td>{cell["text"]}</td>')
|
||
new_rows.append("<tr>" + "".join(new_cells) + "</tr>")
|
||
|
||
return "<tbody>" + "".join(new_rows) + "</tbody>"
|
||
|
||
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)
|
||
|
||
|
||
# ---------- Markdown → HTML ----------
|
||
|
||
def markdown_to_html(text):
|
||
md = markdown.Markdown(extensions=[
|
||
"extra",
|
||
"tables",
|
||
"fenced_code",
|
||
"toc",
|
||
])
|
||
return md.convert(text)
|
||
|
||
|
||
# ---------- 图片 base64 内嵌(HTML 级别) ----------
|
||
|
||
IMG_SRC_RE = re.compile(r'<img([^>]*?)src=["\']([^"\']+)["\']([^>]*)>', re.IGNORECASE)
|
||
|
||
MIME_TABLE = {
|
||
".png": "image/png",
|
||
".jpg": "image/jpeg",
|
||
".jpeg": "image/jpeg",
|
||
".gif": "image/gif",
|
||
".svg": "image/svg+xml",
|
||
".webp": "image/webp",
|
||
}
|
||
|
||
|
||
def guess_mime(ext):
|
||
return MIME_TABLE.get(ext.lower(), "application/octet-stream")
|
||
|
||
|
||
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
|
||
|
||
def repl(match):
|
||
prefix = match.group(1)
|
||
src = match.group(2)
|
||
suffix = match.group(3)
|
||
|
||
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)
|
||
|
||
|
||
# ---------- 目录与锚点 ----------
|
||
|
||
def slugify(text):
|
||
"""生成 HTML 锚点 ID。"""
|
||
anchor = re.sub(r'[^\w\s一-鿿-]', '', text)
|
||
return anchor.strip().replace(" ", "-")[:50]
|
||
|
||
|
||
def generate_toc(html):
|
||
"""从 HTML 中提取 H1-H4 标题,生成目录。
|
||
锚点直接从标题已有的 id 属性读取,确保与 HTML 中的实际 ID 一致。"""
|
||
toc = []
|
||
# 匹配带 id 属性的标题标签
|
||
for m in re.finditer(r'<h([1-4])\s+[^>]*\bid="([^"]*)"[^>]*>(.*?)</h\1>', html, re.DOTALL):
|
||
level = int(m.group(1))
|
||
anchor = m.group(2)
|
||
text = re.sub(r'<.*?>', '', m.group(3)).strip()
|
||
toc.append({"level": level, "text": text, "anchor": anchor})
|
||
return toc
|
||
|
||
|
||
def inject_anchors(html):
|
||
"""为所有 H1-H4 标签注入 id 属性,使侧边栏目录可跳转。
|
||
如果标题已有 id 属性(如自定义渲染器预设的锚点),则保留原 id。"""
|
||
def repl(match):
|
||
level = match.group(1)
|
||
attrs = match.group(2)
|
||
inner = match.group(3)
|
||
# 保留已有的 id 属性(例如渲染器手动设置的锚点)
|
||
if re.search(r'\bid\s*=', attrs):
|
||
return f'<h{level}{attrs}>{inner}</h{level}>'
|
||
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()
|
||
return template.render(
|
||
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,
|
||
toc=toc,
|
||
css=css,
|
||
)
|
||
|
||
|
||
# ---------- 检查脚本 ----------
|
||
|
||
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}")
|
||
|
||
|
||
# ---------- 主入口 ----------
|
||
|
||
def main():
|
||
print("=== Docs as Code 构建 ===")
|
||
print()
|
||
|
||
# 0. 加载配置
|
||
config = load_config()
|
||
print(f"[配置] {config['title']} — {config['version']}")
|
||
print(f" 章节数: {len(config.get('chapters', []))}")
|
||
|
||
# 0a. 加载插件
|
||
print("[插件] 加载扩展模块 ...")
|
||
renderers = load_plugin_modules(RENDERERS_DIR)
|
||
checks = load_plugin_modules(CHECKS_DIR)
|
||
|
||
# 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 列表")
|
||
|
||
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)
|
||
|
||
# MPE 表格合并预处理(^ → rowspan)
|
||
text = preprocess_mpe_tables(text)
|
||
|
||
# KaTeX 预处理
|
||
text = preprocess_katex(text)
|
||
|
||
# 自定义代码块渲染
|
||
text = render_custom_codeblocks_md(text, renderers)
|
||
|
||
# Markdown → HTML
|
||
chapter_html = markdown_to_html(text)
|
||
|
||
# KaTeX 还原(占位符 → HTML 包装的公式)
|
||
chapter_html = postprocess_katex(chapter_html)
|
||
|
||
# 图片 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)
|
||
full_html = postprocess_table_rowspan(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"
|
||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||
output_path = OUTPUT_DIR / output_filename
|
||
|
||
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:,} 字节")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|