# TOC (目录) 功能完整指南
> **最后更新**: 2026-07-10
> **文档版本**: v2.0
> **状态**: ✅ 已完成并上线
---
## 目录
1. [功能概述](#功能概述)
2. [快速开始](#快速开始)
3. [功能需求与设计](#功能需求与设计)
4. [实现细节](#实现细节)
5. [页码编号机制](#页码编号机制)
6. [自动更新功能](#自动更新功能)
7. [测试验证](#测试验证)
8. [已知问题与限制](#已知问题与限制)
9. [故障排查](#故障排查)
---
## 功能概述
### 背景问题
原系统在解析 Word 文档时**完全跳过了 SDT 目录控件**(Structured Document Tag),导致:
- 目录信息完全丢失
- 用户在前端编辑器中看不到目录
- 导出时无法生成目录
### 解决方案
实现了完整的 TOC Block 支持:
- ✅ **解析识别** - 正确识别 Word SDT 目录控件
- ✅ **数据存储** - 保存目录配置(层级、超链接、页码等)
- ✅ **前端展示** - 只读占位符,不可编辑但可删除
- ✅ **智能导出** - 插入 TOC 域代码,自动生成目录
- ✅ **页码控制** - 目录单独一页,正文从第 1 页开始
- ✅ **自动更新** - 使用 WPS/Word API 自动更新域
### 用户价值
1. **文档完整性** - 保留原始文档的目录结构
2. **编辑便利性** - 在编辑器中看到目录位置
3. **导出一致性** - 导出文档自动包含目录
4. **开箱即用** - 下载的文档已生成目录,无需手动更新
---
## 快速开始
### 1. 上传包含目录的文档
```bash
curl -X POST http://localhost:8000/api/v1/documents \
-F "file=@document_with_toc.docx"
```
### 2. 查看解析结果
```json
{
"blocks": [
{
"id": "toc-001",
"type": "toc",
"content": {"title": "目录"},
"metadata": {
"toc_config": {
"levels": "1-3",
"use_hyperlinks": true,
"use_page_numbers": true
},
"readonly": true,
"deletable": true
}
}
]
}
```
### 3. 导出文档
```bash
curl -X POST http://localhost:8000/api/v1/documents/{id}/export/doc \
-o output.docx
```
生成的文档会:
- 自动包含目录(已生成,无需更新)
- 目录单独一页
- 页码从正文第一页开始(第 1 页)
- 支持超链接点击跳转
---
## 功能需求与设计
### TOC Block 数据结构
```json
{
"id": "toc-001",
"blockOrder": 0,
"type": "toc",
"level": 0,
"index": 0,
"content": {
"title": "目录"
},
"wordStyle": "TOC",
"metadata": {
"toc_config": {
"levels": "1-3", // 包含的标题层级
"use_hyperlinks": true, // 是否使用超链接
"use_page_numbers": true, // 是否显示页码
"hide_page_numbers_in_web": true,
"use_outline_levels": true,
"show_leader_dots": true,
"leader_char": "."
},
"is_auto_generated": true, // 标记为自动生成
"readonly": true, // 前端不可编辑
"deletable": true // 前端可删除
}
}
```
### 核心功能需求
| 功能 | 需求 | 状态 |
|------|------|------|
| FR-1 | 解析 Word 文档目录 | ✅ |
| FR-2 | 存储目录配置 | ✅ |
| FR-3 | 前端只读展示 | ✅ |
| FR-4 | 删除目录 | ✅ |
| FR-5 | 导出包含目录 | ✅ |
| FR-6 | 禁止编辑内容 | ✅ |
| FR-7 | 自动更新域 | ✅ |
| FR-8 | 页码从正文开始 | ✅ |
---
## 实现细节
### 1. 解析阶段 (word_parser.py)
#### 检测 SDT 目录控件
```python
# 遍历 body 元素,检测 SDT
for child in body:
tag = child.tag
if tag.endswith('sdt'):
# 提取 TOC 信息
toc_block = _extract_toc_from_sdt(child, block_order, parent_stack)
if toc_block:
blocks.append(toc_block)
```
#### 提取目录配置
从 SDT XML 中提取:
- 目录标题(第一个段落的文本)
- TOC 域代码参数(`TOC \o "1-3" \h \z \u`)
- 层级、超链接、页码等配置
### 2. 导出阶段 (export_service.py)
#### 主要函数
| 函数 | 功能 | 位置 |
|------|------|------|
| `_render_toc_block()` | 渲染 TOC block | Line 1275 |
| `_create_toc_field()` | 创建 TOC 域代码 | Line 1315 |
| `_set_update_fields_on_open()` | 设置自动更新 | Line 1176 |
| `_add_page_number_footer_with_restart()` | 添加页码(重启编号) | Line 1443 |
| `update_document_fields()` | 使用 WPS/Word API 更新域 | Line 17 |
#### 渲染流程
```
1. 检测 TOC block → 设置自动更新域
↓
2. 在目录前添加分页符
↓
3. 添加目录标题(居中,Normal 样式)
↓
4. 插入 TOC 域代码
↓
5. 添加分节符(目录后开始新节)
↓
6. 为新节添加页码页脚(从 1 开始)
↓
7. 渲染其他 blocks(heading, paragraph 等)
↓
8. 保存文档
↓
9. 使用 WPS/Word API 自动更新域(可选)
```
### 3. Word TOC 域结构
```xml
TOC \o "1-3" \h \z \u
占位文本
```
#### TOC 域参数说明
| 参数 | 说明 | 示例 |
|------|------|------|
| `\o "1-3"` | 包含标题层级 1-3 | 一、二、三级标题 |
| `\h` | 使用超链接 | 可点击跳转 |
| `\z` | Web 视图中隐藏页码 | - |
| `\u` | 使用 Unicode | 支持中文 |
---
## 页码编号机制
### 问题:目录计入页码
**修改前**:
```
文档(单节)
├── 目录 → 第 1 页
├── 正文 → 第 2 页 ✗ 不符合需求
```
### 解决方案:使用分节符
**修改后**:
```
文档
├── [第一节:目录]
│ ├── 目录内容
│ └── 页脚:无页码
│
├── [分节符 - 下一页]
│
└── [第二节:正文]
├── 一、基本数据 → 第 1 页 ✓
├── 正文内容...
└── 页脚:第 X 页 / 共 Y 页
```
### 关键技术
#### 1. 创建新节
```python
from docx.enum.section import WD_SECTION
# 添加分节符(下一页开始新节)
new_section = doc.add_section(WD_SECTION.NEW_PAGE)
```
#### 2. 断开页脚链接
```python
# 新节的页脚不链接到前一节
footer = new_section.footer
footer.is_linked_to_previous = False
```
#### 3. 设置页码从 1 开始
```python
# 通过 XML 设置页码起始值
sectPr = section._sectPr
pgNumType = OxmlElement('w:pgNumType')
pgNumType.set(qn('w:start'), '1')
sectPr.append(pgNumType)
```
#### 4. 使用 SECTIONPAGES 域
**修改前**(错误):
```python
add_field(r4._r, ' NUMPAGES ') # 整个文档总页数(包括目录)
```
**修改后**(正确):
```python
add_field(r4._r, ' SECTIONPAGES ') # 当前节总页数(只计算正文)
```
**效果对比**:
| 域代码 | 计算范围 | 示例结果 |
|--------|----------|----------|
| `NUMPAGES` | 整个文档 | 共 23 页(含目录) |
| `SECTIONPAGES` | 当前节(正文) | 共 20 页(不含目录) |
### 页码格式
**页脚内容**:
```
第 {PAGE} 页 / 共 {SECTIONPAGES} 页
```
**实际显示**:
```
第 1 页 / 共 20 页
第 2 页 / 共 20 页
...
第 20 页 / 共 20 页
```
---
## 自动更新功能
### 功能说明
导出后使用 WPS/Word COM API 自动更新文档域,确保用户下载的文档已包含完整目录,无需手动更新。
### 实现原理
参考 `update_toc.py` 的逻辑:
```python
# 1. 启动 WPS/Word
app = win32com.client.Dispatch("Kwps.Application") # 或 Word.Application
app.Visible = False
app.DisplayAlerts = False
# 2. 打开文档
doc = app.Documents.Open(file_path)
# 3. 更新所有域
doc.Fields.Update()
# 4. 再次更新 TOC 域(确保页码正确)
for field in doc.Fields:
if field.Type == 13: # wdFieldTOC
field.Update()
# 5. 保存并关闭
doc.Save()
doc.Close()
app.Quit()
```
### 集成到导出流程
```python
# 在 export.py 中
# 5. 写入文件
file_path.write_bytes(doc_bytes)
# 5.5. 如果文档包含 TOC,自动更新域
has_toc = any(block.get('type') == 'toc' for block in blocks)
if has_toc:
update_success = update_document_fields(str(file_path))
if update_success:
# 域更新成功,重新读取文件大小
file_size = file_path.stat().st_size
# 6. 返回下载链接(已包含更新后的文档)
```
### 容错机制
| 场景 | 处理方式 | 结果 |
|------|---------|------|
| 非 Windows 平台 | 跳过更新 | 文档仍可用,用户打开时自动更新 |
| 未安装 pywin32 | 跳过更新 | 文档仍可用,用户打开时自动更新 |
| WPS/Word 不可用 | 跳过更新 | 文档仍可用,用户打开时自动更新 |
| 更新过程出错 | 跳过更新 | 文档仍可用,用户打开时自动更新 |
### 性能影响
| 操作 | 耗时 |
|------|------|
| 启动 WPS | ~1-2秒 |
| 打开文档 | ~0.5秒 |
| 更新域 | ~0.5秒 |
| 保存文档 | ~0.5秒 |
| **总计** | **~2-3秒** |
**文件大小优化**:更新后文件减小约 20%(去除冗余 XML)
### 依赖安装
```bash
# 安装 pywin32(Windows COM API)
pip install pywin32
# 系统要求
# - Windows 操作系统
# - WPS Office 或 Microsoft Word
```
---
## 测试验证
### 自动化测试
#### 1. 基础导出测试
```bash
python test_toc_export.py
```
**预期结果**:
- ✅ 生成 `tmp/test_toc_export.docx`
- ✅ 文档大小约 45KB
- ✅ 无错误日志
#### 2. 自动更新测试
```bash
python test_toc_update.py
```
**预期结果**:
```
✓ 使用 WPS 成功更新文档域
✓ 域更新成功!
✓ 最终文件大小: 35,373 字节
→ 文件大小变化: -9,774 字节
```
### 手动验证清单
打开生成的 `tmp/test_toc_with_update.docx`,检查:
#### 目录页
- [ ] 目录单独占一页
- [ ] 目录标题居中显示
- [ ] 目录已自动生成(无需手动更新)
- [ ] 目录包含所有一级标题
- [ ] 目录项页码正确
#### 正文页
- [ ] 正文从新的一页开始
- [ ] 第一个一级标题显示"第 1 页"
- [ ] 页码连续递增
- [ ] 页脚显示"第 X 页 / 共 Y 页"
- [ ] "共 Y 页"只计算正文(不包括目录)
#### 交互功能
- [ ] 点击目录项可跳转到对应标题
- [ ] 修改标题后右键"更新域"可刷新目录
- [ ] 页码自动更新
---
## 已知问题与限制
### 1. 仅支持 SDT 目录
**问题**:只识别由 Word"插入目录"功能生成的目录
**影响**:手动创建的目录表格不会被识别
**解决方案**:提示用户使用 Word 的标准目录功能
### 2. 目录项不存储
**问题**:不存储目录中的具体项(标题文本、页码等)
**原因**:目录项由 Word 自动生成,存储后可能不一致
**影响**:前端无法预览目录内容
**解决方案**:前端可从 heading blocks 动态生成预览
### 3. 复杂配置支持有限
**问题**:高级配置(自定义样式、特殊开关)可能丢失
**影响**:复杂目录的某些格式可能不完全保留
**解决方案**:优先支持常用配置,复杂配置在后续版本迭代
### 4. WPS vs Microsoft Word
| 软件 | 自动更新 | 手动更新 |
|------|---------|---------|
| WPS Office | ✅ 提示更新 | ✅ F9 或右键 |
| Microsoft Word | ⚠️ 可能需手动 | ✅ F9 或右键 |
| LibreOffice | ⚠️ 部分支持 | ✅ 手动更新 |
### 5. 非 Windows 平台
**问题**:Linux/macOS 无法使用 COM API 自动更新
**影响**:导出文档需用户手动更新域
**解决方案**:文档已设置自动更新标记,打开时会提示
---
## 故障排查
### 问题 1:目录未生成
**症状**:打开文档后目录区域是空的
**排查步骤**:
1. 检查是否自动更新:WPS 通常会提示"是否更新域"
2. 手动更新:右键点击目录区域 → 选择"更新域"
3. 按 F9 键强制更新所有域
**常见原因**:
- Word 安全设置禁用自动更新
- 文档标题未使用标准样式(Heading 1, 2, 3)
### 问题 2:页码错误(从 2 开始)
**症状**:正文第一页显示"第 2 页"而不是"第 1 页"
**排查步骤**:
1. 检查文档是否使用分节符(不是简单分页符)
2. 检查新节的 `pgNumType` 是否设置 `start="1"`
3. 检查页脚是否断开链接(`is_linked_to_previous = False`)
**解决方案**:
- 确保使用最新版本的代码(已修复)
- 重新导出文档
### 问题 3:"共 X 页"包括目录页
**症状**:页脚显示"共 23 页",但实际正文只有 20 页
**排查步骤**:
1. 检查是否使用 `SECTIONPAGES` 域(不是 `NUMPAGES`)
2. 查看页脚域代码:右键 → 切换域代码
**解决方案**:
- 确保使用最新版本(已使用 `SECTIONPAGES`)
- 重新导出文档
### 问题 4:自动更新失败
**症状**:测试日志显示"域更新失败"
**排查步骤**:
1. 检查是否安装 pywin32:`pip list | grep pywin32`
2. 检查是否安装 WPS/Word
3. 检查文件路径是否为绝对路径
4. 检查文件权限
**解决方案**:
```bash
# 安装 pywin32
pip install pywin32
# 检查 WPS 是否可用
python -c "import win32com.client; app = win32com.client.Dispatch('Kwps.Application'); print('WPS OK')"
# 检查 Word 是否可用
python -c "import win32com.client; app = win32com.client.Dispatch('Word.Application'); print('Word OK')"
```
### 问题 5:目录超链接无法跳转
**症状**:点击目录项无反应
**排查步骤**:
1. 检查 TOC 域是否包含 `\h` 参数
2. 检查标题是否有书签(自动生成)
3. 更新域后重新测试
**解决方案**:
- 确保 `toc_config.use_hyperlinks = true`
- 重新导出文档
- 在 Word 中手动更新域(F9)
---
## 附录
### A. 相关文件清单
#### 核心代码文件
| 文件 | 说明 | 关键函数 |
|------|------|---------|
| `app/services/export_service.py` | 导出服务 | `_render_toc_block`, `update_document_fields` |
| `app/api/v1/export.py` | 导出 API | `export_document` |
| `app/schemas/block.py` | Block Schema | `TOCBlock`, `TOCConfig` |
#### 测试文件
| 文件 | 说明 |
|------|------|
| `test_toc_export.py` | 基础导出测试 |
| `test_toc_update.py` | 自动更新测试 |
#### 文档文件
| 文件 | 说明 |
|------|------|
| `docs/features/toc-complete-guide.md` | 本文档(综合指南) |
| `update_toc.py` | 参考实现 |
### B. API 参考
#### 创建文档(包含目录)
**请求**:
```http
POST /api/v1/documents
Content-Type: multipart/form-data
file:
```
**响应**:
```json
{
"code": 0,
"data": {
"id": "doc-123",
"name": "document.docx",
"created_at": "2026-07-10T10:00:00Z"
}
}
```
#### 获取 Blocks(含 TOC)
**请求**:
```http
GET /api/v1/documents/{documentId}/blocks
```
**响应**:
```json
{
"code": 0,
"data": {
"blocks": [
{
"id": "toc-001",
"type": "toc",
"content": {"title": "目录"},
"metadata": {
"toc_config": {...},
"readonly": true
}
}
]
}
}
```
#### 导出文档
**请求**:
```http
POST /api/v1/export/doc
Content-Type: application/json
{
"document_id": "doc-123",
"style_id": null
}
```
**响应**:
```json
{
"code": 0,
"data": {
"record_id": "export-456",
"download_url": "http://localhost:8000/api/v1/export/records/export-456/download",
"file_name": "document_1720598400000.doc"
}
}
```
### C. Word 域代码参考
| 域类型 | 域代码 | 说明 |
|--------|--------|------|
| 目录 | `TOC \o "1-3" \h \z \u` | 生成目录 |
| 当前页码 | `PAGE` | 第 X 页 |
| 总页数 | `NUMPAGES` | 整个文档页数 |
| 节页数 | `SECTIONPAGES` | 当前节页数 |
| 日期 | `DATE \@ "yyyy-MM-dd"` | 当前日期 |
### D. 联系方式
如有问题或建议,请联系:
- 开发团队:dev@example.com
- 技术支持:support@example.com
- 项目仓库:https://github.com/example/ax-backend
---
**文档结束**