318 lines
12 KiB
Bash
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 "============================================================" |