# 《文档排版——内容与样式分离》实现报告

> 文档编号：IMPL-RPT-023
> 日期：2026-06-12
> 关联设计：[DESIGN-ARCH-023] khyos 文档排版与格式控制规范
> 关联实现：`services/backend/src/services/typeset/*`、`src/templates/docstyles/*.json`、
> `src/services/docTypeset.py`、`src/tools/renderDocument.js`、`src/agents/constraints.js`
> 关联测试：[TEST-RPT-007] 文档排版-测试报告
> 提交：`d7fd385` → `2846656` → `70dd961` → `393de7c`

---

## 1. 目标与背景

解决 agent 写论文/报告时**无法精确控制字体、颜色、页面、换页**的问题。

**自调查结论**：旧路径 `createDocument.js` → `docHelper.py` 的 `text2docx` 把**内容生成与
格式排版彻底耦合**——按换行符把文本一段段裸塞进 docx，零字体/字号/页边距/行距/换页控制。
要让产物像论文，只能让模型自己写排版代码（`\textbf`、docx `w:rPr` XML、HTML `<font>`），而
**模型对格式代码的输出是不确定的**：同一意图这次 `\textbf`、下次 `**`、再下次 `<b>`；字号
忽"三号"忽"16pt"；中文设了 `font.name` 却漏 `eastAsia` 致整篇回退。这正是格式错乱根因。

**改造思路**：把格式决策权从模型手里彻底收走——模型只产语义，程序按模板确定性排版。

---

## 2. 交付物一览

| 层 | 文件 | 行数 | 职责 |
|---|---|---|---|
| 语义 | `src/services/typeset/contentSchema.js` | 283 | 闭合块 AST + 防呆排版代码拦截 |
| 语义 | `src/services/typeset/markdownToAst.js` | 202 | 确定性 Markdown→AST |
| 样式 | `src/services/typeset/styleTemplates.js` | 121 | 模板加载/解析/覆盖 |
| 样式 | `src/templates/docstyles/{default,gbt7714,ieee}.json` | 37×3 | 格式唯一真源（纯数据） |
| 渲染 | `src/services/docTypeset.py` | 551 | python-docx 原子排版 + 写后校验 |
| 工具 | `src/tools/renderDocument.js` | 282 | khyos 工具入口 |
| 禁令 | `src/agents/constraints.js` | +1 行 | 禁手写排版代码红线 |
| 测试 | `tests/services/typeset/*` + `tests/tools/renderDocument.test.js` | 509 | 60 例 |

---

## 3. 关键实现决策

### 3.1 渲染引擎选 python-docx
项目已依赖 python-docx v1.2.0（`createDocument` 既有路径），目标亦明确点名它与 eastAsia 坑。
相比 Pandoc+LaTeX（重、需 TeX 发行版）与 reportlab（PDF 非 docx），python-docx 复用既有
子进程范式（`findPython` + spawn + 滑动超时 + JSON stdout），零新增系统依赖。

### 3.2 模型输入双形态：Markdown 优先，AST 兜底
`_toAst` 接受三态：AST 对象 / AST JSON 串（`{` 开头且含 `blocks` 数组）/ Markdown 串。
Markdown 是模型最自然的语义载体（目标明确祝福"如 Markdown 的 #"），AST 供需要精确结构
（如 figure 路径、reference 条目）时使用。两者都过 `validateDocument` 防呆闸门。

### 3.3 防呆拦截在语义层，不在工具层堆正则
`FORMAT_CODE_PATTERNS` 集中在 `contentSchema.js`，是结构校验的一部分；`renderDocument.js`
只调 `validateDocument` 一次即获拦截。code 块正文豁免（原样渲染等宽，永不解释），避免把
合法代码清单误杀。

### 3.4 换页确定性 API，绝不靠空行
换页只有三个确定性来源：结构化 `{type:"pagebreak"}` 块、Markdown `[[newpage]]`/
`<<<pagebreak>>>` 哨兵、模板"H1 前自动换页"策略。`markdownToAst` 显式把空行仅作段落分隔、
把 `---` 水平线**忽略**（不换页），从源头杜绝"模型插空行占位换页"的不确定行为。

### 3.5 写后必验 + 就地打补丁，绝不退回模型
`_verify_and_patch` 反解已存 docx：页面非 A4 则改 section 尺寸；任何有文本的 run 漏
eastAsia 则用 XML 补 `w:rFonts/@w:eastAsia`；H1 字号抽检不符则修正。任何不合都在引擎内
修复重存——**绝不把文件退回模型重写**，消除二次不确定性。

---

## 4. 过程中发现并修复的真实缺陷

| # | 缺陷 | 修复 |
|---|------|------|
| 1 | HTML 标签正则 `<\s*\/?\s*(?:b\|i\|…)` 过宽：不等式散文 `a < b 与 x > y` 中 `< b … >` 被误判为标签 | 收紧为标签名须**紧跟** `<`（去掉 `\s*`）：`<\/?(?:b\|i\|…)`，真实标签 `<b>`/`<font …>` 仍命中，`a < b` 不再误杀 |
| 2 | 测试 doc 注释含 `**bold**/*italic*`，其中 `*/` 提前闭合块注释致 `SyntaxError` | 改写注释文字去除 `*/` 字面（与历史同类 bug 一致） |
| 3 | 023 设计文档误写入 stray 目录 `services/backend/docs/`（实际 docs 在仓库根） | 迁回 `docs/03_DESIGN_设计/` 并删 stray 树，索引链接重新解析正确 |

---

## 5. 验证证据

```
Test Suites: 4 passed, 4 total
Tests:       60 passed, 60 total
```
端到端用例 `renders a real .docx and verifies A4 + applied formatting`（256ms）：以 gbt7714
渲染含标题/正文强调/列表/换页/表格的真 .docx，再用 python-docx 反解断言
`page_width/height≈A4`、H1/H2 在位、**每个有文本的中文 run 的 eastAsia 全覆盖
（missing_eastAsia=0）**。详见 [TEST-RPT-007]。

回归：`constraints.js` 单行新增经 `node --test tests/agentConstraints.test.js` 6/6 通过，
禁令单一真源体例不破。

---

## 6. 边界与未尽事项

- **只新增不改核心**：未触碰 `createDocument` 老路径、核心调度器（`toolUseLoop`/
  `toolExecutionEngine`）、任何无关业务；唯一对既有文件的改动是 `constraints.js` 增一条禁令红线。
- **IEEE 双栏**：python-docx 不便实现，`ieee.json` 取单栏近似，双栏需后处理（已在模板 description 标注）。
- **"N 字符"缩进**：按 N×字号磅值近似 CJK 字宽，非精确字符网格；对中文场景足够。
