Files
worldmodel/plans/PRISM/tools/build_book.sh
T

318 lines
12 KiB
Bash

#!/usr/bin/env bash
# 构建 PRISM 出版级 PDF 全书
# 1. 预处理 14 个 markdown(剥离 emoji、改链接)
# 2. pandoc 转 LaTeX body
# 3. 注入模板 → XeLaTeX 编译两次(目录)
# 4. 用 pypdf 合并 cover + body → PRISM_Whole.pdf
#
# 用法:
# bash plans/PRISM/tools/build_book.sh
#
# 输出:
# plans/PRISM/PRISM_Book.pdf 正文部分
# plans/PRISM/PRISM_Whole.pdf 封面 + 正文 完整版
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
PRISM_DIR="$ROOT/plans/PRISM"
TOOLS_DIR="$PRISM_DIR/tools"
BUILD_DIR="$PRISM_DIR/.build"
mkdir -p "$BUILD_DIR"
# ── 版本与构建日期(注入封面/页眉/章节正文)──────────────────────────
if [[ -f "$PRISM_DIR/VERSION" ]]; then
VERSION="$(tr -d '[:space:]' < "$PRISM_DIR/VERSION")"
else
VERSION="0.0.0"
fi
BUILD_DATE="$(date +'%Y-%m-%d')"
echo "==> Building PRISM Book v${VERSION} (${BUILD_DATE})"
echo "==> 1a) 生成统计章节 16_stats.md"
python3 "$TOOLS_DIR/gen_stats.py" "$PRISM_DIR" "$PRISM_DIR/16_stats.md"
echo "==> 1a') 生成变更日志章节 17_changelog.md (从 CHANGELOG.md)"
python3 "$TOOLS_DIR/gen_changelog_chapter.py" \
"$PRISM_DIR/CHANGELOG.md" \
"$PRISM_DIR/17_changelog.md" \
"$VERSION" "$BUILD_DATE"
echo "==> 1) 预处理 markdown 文件"
# 章节顺序
CHAPTERS=(
"README.md"
"00_overview.md"
"01_capability_decomposition.md"
"02_architecture.md"
"03_data_schema.md"
"04_pipeline_A_iphone_offline.md"
"05_pipeline_B_relocalization.md"
"06_pipeline_C_online_perception.md"
"07_pipeline_D_consolidation.md"
"08_runtime_timeline.md"
"09_roadmap.md"
"10_tech_stack.md"
"11_world_model_bridge.md"
"12_risks.md"
"13_evaluation.md"
"14_mvp.md"
"15_glossary.md"
"16_stats.md"
"18_lyra_inspirations.md"
"17_changelog.md"
)
# Python 预处理:把每个 md 的一级标题改成 LaTeX \chapter,
# 移除 emoji / 修复链接 / 删除 raw HTML
python3 - <<'PYEOF'
import re, sys, pathlib
PRISM_DIR = pathlib.Path("plans/PRISM")
OUT = PRISM_DIR / ".build" / "merged.md"
# 章节顺序
order = [
"README.md",
"00_overview.md",
"01_capability_decomposition.md",
"02_architecture.md",
"03_data_schema.md",
"04_pipeline_A_iphone_offline.md",
"05_pipeline_B_relocalization.md",
"06_pipeline_C_online_perception.md",
"07_pipeline_D_consolidation.md",
"08_runtime_timeline.md",
"09_roadmap.md",
"10_tech_stack.md",
"11_world_model_bridge.md",
"12_risks.md",
"13_evaluation.md",
"14_mvp.md",
"15_glossary.md",
"16_stats.md",
"18_lyra_inspirations.md",
"17_changelog.md",
]
# 章节显示名(替换原一级标题,使目录整齐)
chapter_titles = {
"README.md": "项目总览 (README)",
"00_overview.md": "第 0 章 总览与命名由来",
"01_capability_decomposition.md": "第 1 章 两方案能力解构",
"02_architecture.md": "第 2 章 四层空间记忆架构",
"03_data_schema.md": "第 3 章 统一数据模型",
"04_pipeline_A_iphone_offline.md": "第 4 章 管线 A:iPhone 离线建图",
"05_pipeline_B_relocalization.md": "第 5 章 管线 B:ZED 重定位握手",
"06_pipeline_C_online_perception.md": "第 6 章 管线 C:在线感知与差异检测",
"07_pipeline_D_consolidation.md": "第 7 章 管线 D:记忆巩固",
"08_runtime_timeline.md": "第 8 章 端到端运行时序",
"09_roadmap.md": "第 9 章 实施路线图",
"10_tech_stack.md": "第 10 章 技术栈选型",
"11_world_model_bridge.md": "第 11 章 与世界模型衔接",
"12_risks.md": "第 12 章 风险与对策",
"13_evaluation.md": "第 13 章 评测指标",
"14_mvp.md": "第 14 章 最小可复现 demo",
"15_glossary.md": "第 15 章 缩略语表",
"16_stats.md": "第 16 章 文档与代码统计",
"18_lyra_inspirations.md": "第 18 章 Lyra 2.0 启发的设计原则",
"17_changelog.md": "第 17 章 变更日志",
}
# 常见 emoji / 装饰符号 → 文字或删除
EMOJI_MAP = {
"🌈":"", "📱":"", "📷":"", "🤖":"", "🚀":"", "💻":"", "📊":"", "📋":"",
"📚":"", "🎓":"", "🎬":"", "🏗️":"", "🏨":"", "💰":"", "📐":"", "🌟":"",
"⚠️":"[!]", "✅":"[OK]", "❌":"[X]", "🟢":"[L]", "🟡":"[M]", "🔴":"[H]",
"🎯":"", "📈":"", "📞":"", "📍":"", "🗺️":"", "📦":"", "🔧":"", "🤝":"",
"📝":"", "🌐":"", "📄":"", "📧":"", "💡":"", "💬":"", "📤":"", "📥":"",
"▶":">", "◀":"<", "⭐":"★",
"↑":"↑", "↓":"↓",
# 第二轮补充:xelatex_pass2.log 中报告的缺失字符
"🔬":"", "🧑":"", "💼":"", "🎤":"", "📹":"",
"→":"->", "↔":"<->",
"①":"(1)", "②":"(2)", "③":"(3)", "④":"(4)",
"✓":"[v]",
"・":"·",
}
def preprocess(text: str, source_name: str) -> str:
# 1) emoji → 替换
for k, v in EMOJI_MAP.items():
text = text.replace(k, v)
# 2) 移除起首 yaml/front matter(不会有,跳过)
# 3) 删除 raw HTML 注释 / details
text = re.sub(r"<!--.*?-->", "", text, flags=re.S)
text = re.sub(r"</?details[^>]*>", "", text)
text = re.sub(r"</?summary[^>]*>", "", text)
# 4) 替换文件名链接 [`xx.md`](path) → \emph{xx.md} 等;
# Pandoc 默认会把这些保留为超链接(指向不存在的文件),
# 我们把 .md 链接收敛为只显示文本,避免大量"红色断链"。
# 直接降级为反引号代码标记,让 pandoc 自己处理转义(它会输出 \texttt{}+下划线转义)
def md_link_replace(m):
token = m.group(1)
# 保留为 inline-code: `xxx`
return f"`{token}`"
text = re.sub(r"\[`([^`\]]+)`\]\([^)]+\)", md_link_replace, text)
# 普通 [text](xxx.md...) 不带反引号的:仅保留文本(纯文本无需转义)
text = re.sub(r"\[([^\]]+?)\]\([^)]*\.md[^)]*\)",
lambda m: m.group(1), text)
# 5) 把第一行 H1 替换为指定章节标题
title = chapter_titles.get(source_name, source_name)
lines = text.splitlines()
for i, l in enumerate(lines):
if l.startswith("# "):
lines[i] = f"# {title}"
break
else:
lines.insert(0, f"# {title}")
# 6) 降级:把 ## 起的所有标题保留;一级 # 后面紧跟的 "## —— xxx" 类副标题
# 保持原样,但避免 pandoc 把它认成另一个 chapter
out = "\n".join(lines)
# 7) 把 horizontal rule 上下的"章节版本/估计阅读时间/关键收获"等元信息
# 保留(它们是有用尾页内容)
return out
# 拼接
parts = []
for fn in order:
p = PRISM_DIR / fn
if not p.exists():
print(f"[WARN] missing {p}")
continue
body = p.read_text(encoding="utf-8")
body = preprocess(body, fn)
# 每章前换页
parts.append("\n\n\\newpage\n\n" + body + "\n")
OUT.write_text("\n".join(parts), encoding="utf-8")
print(f"[ok] merged → {OUT} ({OUT.stat().st_size/1024:.1f} KB)")
PYEOF
echo "==> 1b) 渲染 mermaid 图块 (mmdc → PNG)"
FIG_DIR="$BUILD_DIR/figs"
if [[ ! -x "$TOOLS_DIR/node_modules/.bin/mmdc" ]]; then
echo "[ERROR] mmdc 未安装,请先执行:"
echo " cd $TOOLS_DIR && npm install @mermaid-js/mermaid-cli"
exit 1
fi
python3 "$TOOLS_DIR/render_mermaid.py" \
"$BUILD_DIR/merged.md" \
"$BUILD_DIR/merged.md" \
"$FIG_DIR"
echo "==> 2) pandoc → LaTeX body"
pandoc "$BUILD_DIR/merged.md" \
--from=markdown+pipe_tables+grid_tables+raw_tex+task_lists+yaml_metadata_block \
--to=latex \
--top-level-division=chapter \
--listings=false \
--wrap=preserve \
--highlight-style=tango \
-o "$BUILD_DIR/body.tex"
# 把 body 注入模板,同时把 __PRISM_VERSION__ / __PRISM_BUILD_DATE__ 占位符替换为真实值;
# 并对 pandoc 生成的"自然宽度 longtable"做自适应换行后处理。
export PRISM_VERSION="$VERSION"
export PRISM_BUILD_DATE="$BUILD_DATE"
python3 - <<'PYEOF'
import os, pathlib, re
tpl = pathlib.Path("plans/PRISM/tools/book_template.tex").read_text(encoding="utf-8")
body = pathlib.Path("plans/PRISM/.build/body.tex").read_text(encoding="utf-8")
version = os.environ.get("PRISM_VERSION", "0.0.0")
build_date = os.environ.get("PRISM_BUILD_DATE", "unknown")
tpl = tpl.replace("__PRISM_VERSION__", version)
tpl = tpl.replace("__PRISM_BUILD_DATE__", build_date)
# pandoc 默认生成 \begin{longtable}[]{@{}lll@{}} 这种自然宽列规范,
# 长内容(URL/中文长句/代码)会冲出页面。把所有形如 @{}[lcr]+@{} 的
# 自然宽列规范替换为等宽 p{w} 列,启用单元格内换行。
LT_HEAD_RE = re.compile(
r"\\begin\{longtable\}(\[[^\]]*\])?\{@\{\}([lcr]+)@\{\}\}"
)
def _replace_lt(m):
opts = m.group(1) or ""
cols = m.group(2)
n = len(cols)
col_spec = " ".join(
r">{\raggedright\arraybackslash}p{(\linewidth - "
f"{2*n}" + r"\tabcolsep)/" + f"{n}" + r"}"
for _ in range(n)
)
return f"\\begin{{longtable}}{opts}{{@{{}}{col_spec}@{{}}}}"
n_fixed = len(LT_HEAD_RE.findall(body))
body = LT_HEAD_RE.sub(_replace_lt, body)
print(f"[longtable] auto-wrapped {n_fixed} natural-width tables -> p columns")
out = tpl.replace("__PRISM_BODY_PLACEHOLDER__", body)
pathlib.Path("plans/PRISM/.build/book.tex").write_text(out, encoding="utf-8")
print("[ok] injected -> plans/PRISM/.build/book.tex")
PYEOF
echo "==> 3) XeLaTeX 编译 (两次,生成目录)"
cd "$BUILD_DIR"
# 用 -interaction=nonstopmode 跑两遍;第二遍解决 \tableofcontents 引用
for i in 1 2; do
echo " pass $i ..."
xelatex -interaction=nonstopmode -halt-on-error -file-line-error \
book.tex > "xelatex_pass${i}.log" 2>&1 || {
echo "[ERROR] XeLaTeX pass $i failed; tail of log:"
tail -60 "xelatex_pass${i}.log"
exit 1
}
done
cd - >/dev/null
cp "$BUILD_DIR/book.pdf" "$PRISM_DIR/PRISM_Book.pdf"
echo "[ok] body PDF → $PRISM_DIR/PRISM_Book.pdf ($(du -h "$PRISM_DIR/PRISM_Book.pdf" | cut -f1))"
echo "==> 4) 合并 cover + body → PRISM_Whole.pdf"
python3 - <<PYEOF
from pypdf import PdfWriter, PdfReader
import pathlib
prism = pathlib.Path("plans/PRISM")
cover = prism / "PRISM_Cover.pdf"
body = prism / "PRISM_Book.pdf"
out = prism / "PRISM_Whole.pdf"
w = PdfWriter()
if cover.exists():
for p in PdfReader(str(cover)).pages:
w.add_page(p)
print(f" + cover: {len(PdfReader(str(cover)).pages)} pages")
else:
print(f" [WARN] cover not found: {cover}")
# 从正文 PDF 继承 metadata(Title/Author/Subject/Keywords),
# 这样合并后的 Whole.pdf 也带 v__VERSION__ 标识。
body_reader = PdfReader(str(body))
for p in body_reader.pages:
w.add_page(p)
print(f" + body: {len(body_reader.pages)} pages")
src_meta = body_reader.metadata or {}
w.add_metadata({
"/Title": src_meta.get("/Title", "PRISM"),
"/Author": src_meta.get("/Author", "Project Planning Team"),
"/Subject": src_meta.get("/Subject", "PRISM"),
"/Keywords": src_meta.get("/Keywords", "PRISM"),
"/Creator": src_meta.get("/Creator", "LaTeX + pypdf"),
})
with open(out, "wb") as f:
w.write(f)
print(f"[ok] merged → {out} ({out.stat().st_size/1024/1024:.2f} MB)")
print(f" Title: {src_meta.get('/Title', '<none>')}")
print(f" Subject: {src_meta.get('/Subject', '<none>')}")
PYEOF
echo ""
echo "============================================================"
echo " ✅ PRISM 全书构建完成"
echo "------------------------------------------------------------"
echo " 封面: $PRISM_DIR/PRISM_Cover.pdf"
echo " 正文: $PRISM_DIR/PRISM_Book.pdf"
echo " 整合全本: $PRISM_DIR/PRISM_Whole.pdf"
echo "============================================================"