diff --git a/.github/workflows/book.yml b/.github/workflows/book.yml
new file mode 100644
index 00000000..4dbc0f46
--- /dev/null
+++ b/.github/workflows/book.yml
@@ -0,0 +1,105 @@
+name: Book CI
+
+on:
+ push:
+ branches: [main]
+ paths:
+ - '00-前言.md'
+ - '第一部分-基础篇/**'
+ - '第二部分-核心系统篇/**'
+ - '第三部分-高级模式篇/**'
+ - '第四部分-工程实践篇/**'
+ - '附录/**'
+ - 'en/**'
+ - 'yuyu-book/**'
+ - 'scripts/**'
+ - 'tests/**'
+ - 'package.json'
+ - 'package-lock.json'
+ - '.github/workflows/book.yml'
+ pull_request:
+ paths:
+ - '00-前言.md'
+ - '第一部分-基础篇/**'
+ - '第二部分-核心系统篇/**'
+ - '第三部分-高级模式篇/**'
+ - '第四部分-工程实践篇/**'
+ - '附录/**'
+ - 'en/**'
+ - 'yuyu-book/**'
+ - 'scripts/**'
+ - 'tests/**'
+ - 'package.json'
+ - 'package-lock.json'
+ - '.github/workflows/book.yml'
+
+permissions:
+ contents: read
+
+jobs:
+ book:
+ runs-on: ubuntu-latest
+ # A healthy run finishes in under 4 minutes, so 30 was never about the
+ # build — it was the ceiling that killed a run whose apt mirror crawled.
+ # Kept generous enough that a slow mirror still ships a book.
+ timeout-minutes: 45
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '22'
+ cache: npm
+
+ - uses: actions/setup-python@v5
+ with:
+ python-version: '3.12'
+
+ - run: npm ci
+
+ - run: python -m pip install -r yuyu-book/requirements.txt
+
+ # Playwright ships as a Python dependency here, so the browser comes from
+ # `python -m playwright install`, not `npx playwright`. The build is run
+ # without --browser on purpose: only the auto-detected msedge/chrome/
+ # chromium launches pass --allow-file-access-from-files, which the mermaid
+ # harness needs to import the ESM bundle from file://. Noto CJK faces are
+ # required for the printed pages and the diagram labels.
+ #
+ # Two downloads, two steps, each with its own clock. One run drew an
+ # azure.archive.ubuntu.com serving fonts-noto-cjk at 25 kB/s: it spent 26
+ # minutes on the font package, never reached the browser, and the job died
+ # as "cancelled" with the culprit buried in the log. Bounding each fetch
+ # says which mirror failed, on the step that failed, in minutes.
+ - name: Install CJK fonts
+ timeout-minutes: 12
+ run: |
+ sudo apt-get -o Acquire::Retries=3 update
+ sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 \
+ install -y fonts-noto-cjk
+
+ - name: Install Chromium
+ timeout-minutes: 15
+ run: python -m playwright install --with-deps chromium
+
+ - run: python scripts/check_book.py
+
+ - run: npm run check:diagrams
+
+ - name: Unit tests
+ run: |
+ python -m unittest discover -s tests -v
+ python -m unittest discover -s yuyu-book/tests -v
+
+ # Also warms yuyu-book/.cache/mermaid so the PDF pass reuses the plates.
+ - name: Assemble and audit both editions
+ run: python yuyu-book/build.py --check
+
+ - name: Build both PDF editions
+ run: python yuyu-book/build.py --lang both --pdf
+
+ - uses: actions/upload-artifact@v4
+ with:
+ name: yuyu-book-dist
+ path: yuyu-book/dist/*
+ if-no-files-found: error
diff --git a/.gitignore b/.gitignore
index 7cf0d320..21af8d2a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,9 @@
node_modules/
__pycache__/
+
+# derived by yuyu-book/build.py; CI publishes dist as an artifact
+yuyu-book/dist/
+yuyu-book/.cache/
+yuyu-book/cover-art.png
+yuyu-book/.scratch/
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 00000000..098c1e3d
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,203 @@
+# 参与修订
+
+先读 [STRUCTURE.md](STRUCTURE.md) 了解仓库的两层契约:**Markdown 书稿是唯一内容源,构建器只读不写**;锚点、目录、图号、脚注、书签、页码都在构建时派生。书稿本身可以改,但改动只能由人决定——构建器永不回写正文,v0.3.1 里那三处自相矛盾的数字是人工核对后落的原稿修正,并由 `test_docclaims` 的门禁钉住(正文声明的总数必须等于它下面那张表/那张图)。本文讲环境、命令、改动流程和门禁。
+
+## 1. 环境
+
+```bash
+# Node 22+(package.json 的 engines 要求 >=22),用于 mermaid 解析与图板渲染
+npm ci # 安装 mermaid 11.17.2 + jsdom 26.1.0
+
+# Python 侧依赖(Markdown / Pygments / playwright / PyMuPDF / fonttools / Pillow)
+python -m pip install -r yuyu-book/requirements.txt
+```
+
+渲染 Mermaid 图版和打印 PDF 需要一个 Chromium 系浏览器。构建器按 **msedge → chrome → Playwright 自带 chromium** 的顺序自动探测,装了前两者之一就直接可用;都没有时:
+
+```bash
+python -m playwright install chromium # 注意是 Python 包提供的 CLI,不是 npx
+```
+
+也可以显式指定:`--browser "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe"`。**一个坑**:走自动探测分支时构建器会带上 `--allow-file-access-from-files`,而用 `--browser` 指定可执行文件时不会带;如果图板报"harness failed to load",先去掉 `--browser` 让它自动探测。只想跑通构建而不渲染图版,可以用 `--mermaid source`(完全不启动浏览器,图源以代码块排版)。
+
+## 2. 常用命令
+
+```bash
+python yuyu-book/build.py --check # 装配两语版并跑审计,输出进临时目录,不落 dist/、不出 PDF
+python yuyu-book/build.py --lang zh # 只出中文版 HTML 到 yuyu-book/dist/
+python yuyu-book/build.py --lang en --pdf # 英文版 HTML + PDF
+python yuyu-book/build.py --regen-index # 从附录 D 重建 subject-index.json(随后仍会继续完整构建)
+```
+
+仓库根 `Makefile` 把常用组合包了一层:
+
+```bash
+make # 列出可用目标
+make book # 双语 HTML
+make book-pdf # 双语 HTML + PDF
+make check # build.py --check + 两套单测 + scripts/check_book.py + npm run check:diagrams
+make index # --regen-index
+make clean # 删除 yuyu-book/dist
+```
+
+两个变量可以覆盖:`make PYTHON=python3`(Linux CI 用)、`make BOOK_LANG=zh`(只出一语;刻意不叫 `LANG`,那是 shell 已经在导出的 locale 变量)。
+
+改动前后都建议跑一遍既有内容门:
+
+```bash
+python scripts/check_book.py # 相对链接落点、围栏闭合、Mermaid 里的字面量 \n
+npm run check:diagrams # 用锁定的 mermaid 解析器逐图 parse
+python -m unittest discover -s tests # 仓库现有单测(star history 等)
+python -m unittest discover -s yuyu-book/tests # 构建契约:manifest/来源/围栏与图题/交叉引用/审计/版式 WCAG
+```
+
+`yuyu-book/tests` 不需要浏览器、不渲染图、不写书稿;它把 `--check` 抓不到的东西钉死:附录 D 表格到索引的映射、`MermaidBlock.line` 必须仍是文件里的行号(图题靠它对位)、`第 N 章` 在 `//')
+ out, floor = render.floor_wide_svg(svg)
+ self.assertEqual(floor, "1700px")
+ self.assertIn('
b
") + self.assertIn('class="hr-rule"', out) + self.assertIn("currentColor", out) + self.assertNotIn("', html) + self.assertIn("Ⅱ", pages._ROMAN[2]) + + def test_part_divider_draws_the_road_before_its_stops(self): + """The strip above the list is the part's itinerary: one ring for every + chapter the divider then names, so the count is not decoration.""" + part = self.manifest["parts"][1] + rows = [(u["id"], u["id"][-2:], u["id"].upper()) for u in part["units"]] + html = pages.part_divider(part, "zh", 2, rows) + self.assertEqual(html.count('r="3.3"'), len(rows)) + self.assertLess(html.index("pd-route"), html.index("pd-list")) + + +class Compass(Config): + def test_three_cards_and_cited_source_chips(self): + guide = self.manifest["guides"]["ch07"] + html = pages.compass(self.units["ch07"], guide, self.sources, "zh") + for key in ("question", "deliverable", "boundary"): + self.assertIn(f'compass-card {key}', html) + self.assertIn(guide[key]["zh"], html) + self.assertEqual(html.count("source-chip"), len(guide["sources"])) + for sid in guide["sources"]: + self.assertIn(f'href="#source-{sid}"', html) + + def test_english_compass_uses_english_copy(self): + guide = self.manifest["guides"]["ch01"] + html = pages.compass(self.units["ch01"], guide, self.sources, "en") + self.assertIn(guide["question"]["en"], html) + self.assertNotIn(guide["question"]["zh"], html) + + def test_chips_point_at_real_registry_entries(self): + registry = pages.source_registry(self.sources, "zh") + for uid, guide in self.manifest["guides"].items(): + for sid in guide.get("sources", []): + self.assertIn(f'id="source-{sid}"', registry, uid) + + +class Contents(Config): + def _entries(self): + entries = [{"kind": "row", "anchor": "front", "no": "00", "title": "前言"}] + for i, part in enumerate(self.manifest["parts"], 1): + entries.append({"kind": "part", "anchor": part["id"], + "no": pages._ROMAN[i], "title": part["title"]["zh"]}) + for u in part["units"]: + unit = self.units[u["id"]] + entries.append({"kind": "row", "anchor": u["id"], + "no": unit.no_plain, "title": unit.title}) + entries.append({"kind": "hr"}) + for apx in self.manifest["appendices"]: + unit = self.units[apx["id"]] + entries.append({"kind": "row", "anchor": apx["id"], + "no": unit.no_plain, "title": unit.title}) + return entries + + def test_every_entry_gets_a_page_ref_placeholder(self): + html = pages.toc_html(self._entries(), "zh") + for e in self._entries(): + if e["kind"] == "hr": + continue + self.assertIn(f'data-ref="{e["anchor"]}"', html, e["anchor"]) + self.assertIn('class="page-ref', html) + + def test_parts_and_their_rows_group_so_a_page_break_cannot_strand_a_heading(self): + html = pages.toc_html(self._entries(), "zh") + groups = re.findall(r'
([^<]*)
', html_zh) + self.assertIsNotNone(note, "the Chinese block shipped without its rule") + self.assertIn("部首", note.group(1)) + + def test_the_english_index_does_not_inherit_chinese_rows(self): + entries = load_json(BOOK_DIR / "subject-index.json") + html_en = pages.subject_index_page(entries, "en") + for t in re.findall(r'([^<]*)', html_en): + self.assertNotRegex(t, r"[\u4e00-\u9fff]", + "a Chinese-only row leaked into the English index") + self.assertNotIn("中文", html_en) + + def test_paper_reserves_the_folio_slot_the_pdf_fills(self): + """A screen has no pages, so an index row shows only its locator chip and + the row's folio placeholder stays hidden. Paper is the other case: the + placeholder exists in the markup, but the print block inherited the + screen's `display:none` and the PDF pass had no row list to stamp, so the + slot was neither reserved nor filled.""" + entries = load_json(BOOK_DIR / "subject-index.json") + printed = [e for e in entries if e["zh"]["refs"]] + html = pages.subject_index_page(entries, "zh") + rows = re.findall(r'x
", "", "") + self.assertIn('', html) + self.assertIn(" + + +{rail} +