最后更新: 2026-07-10
文档版本: v2.0
状态: ✅ 已完成并上线
原系统在解析 Word 文档时完全跳过了 SDT 目录控件(Structured Document Tag),导致:
实现了完整的 TOC Block 支持:
curl -X POST http://localhost:8000/api/v1/documents \
-F "file=@document_with_toc.docx"
{
"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
}
}
]
}
curl -X POST http://localhost:8000/api/v1/documents/{id}/export/doc \
-o output.docx
生成的文档会:
{
"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 | 页码从正文开始 | ✅ |
# 遍历 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 \o "1-3" \h \z \u)| 函数 | 功能 | 位置 |
|---|---|---|
_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 自动更新域(可选)
<w:p>
<w:r>
<!-- 域开始 -->
<w:fldChar w:fldCharType="begin" w:dirty="1"/>
<!-- 域指令 -->
<w:instrText xml:space="preserve">TOC \o "1-3" \h \z \u</w:instrText>
<!-- 域分隔符 -->
<w:fldChar w:fldCharType="separate"/>
<!-- 占位符(更新后会被目录内容替换) -->
<w:r><w:t>占位文本</w:t></w:r>
<!-- 域结束 -->
<w:fldChar w:fldCharType="end"/>
</w:r>
</w:p>
| 参数 | 说明 | 示例 |
|---|---|---|
\o "1-3" |
包含标题层级 1-3 | 一、二、三级标题 |
\h |
使用超链接 | 可点击跳转 |
\z |
Web 视图中隐藏页码 | - |
\u |
使用 Unicode | 支持中文 |
修改前:
文档(单节)
├── 目录 → 第 1 页
├── 正文 → 第 2 页 ✗ 不符合需求
修改后:
文档
├── [第一节:目录]
│ ├── 目录内容
│ └── 页脚:无页码
│
├── [分节符 - 下一页]
│
└── [第二节:正文]
├── 一、基本数据 → 第 1 页 ✓
├── 正文内容...
└── 页脚:第 X 页 / 共 Y 页
from docx.enum.section import WD_SECTION
# 添加分节符(下一页开始新节)
new_section = doc.add_section(WD_SECTION.NEW_PAGE)
# 新节的页脚不链接到前一节
footer = new_section.footer
footer.is_linked_to_previous = False
# 通过 XML 设置页码起始值
sectPr = section._sectPr
pgNumType = OxmlElement('w:pgNumType')
pgNumType.set(qn('w:start'), '1')
sectPr.append(pgNumType)
修改前(错误):
add_field(r4._r, ' NUMPAGES ') # 整个文档总页数(包括目录)
修改后(正确):
add_field(r4._r, ' SECTIONPAGES ') # 当前节总页数(只计算正文)
效果对比:
| 域代码 | 计算范围 | 示例结果 |
|---|---|---|
NUMPAGES |
整个文档 | 共 23 页(含目录) |
SECTIONPAGES |
当前节(正文) | 共 20 页(不含目录) |
页脚内容:
第 {PAGE} 页 / 共 {SECTIONPAGES} 页
实际显示:
第 1 页 / 共 20 页
第 2 页 / 共 20 页
...
第 20 页 / 共 20 页
导出后使用 WPS/Word COM API 自动更新文档域,确保用户下载的文档已包含完整目录,无需手动更新。
参考 update_toc.py 的逻辑:
# 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()
# 在 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)
# 安装 pywin32(Windows COM API)
pip install pywin32
# 系统要求
# - Windows 操作系统
# - WPS Office 或 Microsoft Word
python test_toc_export.py
预期结果:
tmp/test_toc_export.docxpython test_toc_update.py
预期结果:
✓ 使用 WPS 成功更新文档域
✓ 域更新成功!
✓ 最终文件大小: 35,373 字节
→ 文件大小变化: -9,774 字节
打开生成的 tmp/test_toc_with_update.docx,检查:
问题:只识别由 Word"插入目录"功能生成的目录
影响:手动创建的目录表格不会被识别
解决方案:提示用户使用 Word 的标准目录功能
问题:不存储目录中的具体项(标题文本、页码等)
原因:目录项由 Word 自动生成,存储后可能不一致
影响:前端无法预览目录内容
解决方案:前端可从 heading blocks 动态生成预览
问题:高级配置(自定义样式、特殊开关)可能丢失
影响:复杂目录的某些格式可能不完全保留
解决方案:优先支持常用配置,复杂配置在后续版本迭代
| 软件 | 自动更新 | 手动更新 |
|---|---|---|
| WPS Office | ✅ 提示更新 | ✅ F9 或右键 |
| Microsoft Word | ⚠️ 可能需手动 | ✅ F9 或右键 |
| LibreOffice | ⚠️ 部分支持 | ✅ 手动更新 |
问题:Linux/macOS 无法使用 COM API 自动更新
影响:导出文档需用户手动更新域
解决方案:文档已设置自动更新标记,打开时会提示
症状:打开文档后目录区域是空的
排查步骤:
常见原因:
症状:正文第一页显示"第 2 页"而不是"第 1 页"
排查步骤:
pgNumType 是否设置 start="1"is_linked_to_previous = False)解决方案:
症状:页脚显示"共 23 页",但实际正文只有 20 页
排查步骤:
SECTIONPAGES 域(不是 NUMPAGES)解决方案:
SECTIONPAGES)症状:测试日志显示"域更新失败"
排查步骤:
pip list | grep pywin32解决方案:
# 安装 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')"
症状:点击目录项无反应
排查步骤:
\h 参数解决方案:
toc_config.use_hyperlinks = true| 文件 | 说明 | 关键函数 |
|---|---|---|
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 |
参考实现 |
请求:
POST /api/v1/documents
Content-Type: multipart/form-data
file: <Word文档二进制>
响应:
{
"code": 0,
"data": {
"id": "doc-123",
"name": "document.docx",
"created_at": "2026-07-10T10:00:00Z"
}
}
请求:
GET /api/v1/documents/{documentId}/blocks
响应:
{
"code": 0,
"data": {
"blocks": [
{
"id": "toc-001",
"type": "toc",
"content": {"title": "目录"},
"metadata": {
"toc_config": {...},
"readonly": true
}
}
]
}
}
请求:
POST /api/v1/export/doc
Content-Type: application/json
{
"document_id": "doc-123",
"style_id": null
}
响应:
{
"code": 0,
"data": {
"record_id": "export-456",
"download_url": "http://localhost:8000/api/v1/export/records/export-456/download",
"file_name": "document_1720598400000.doc"
}
}
| 域类型 | 域代码 | 说明 |
|---|---|---|
| 目录 | TOC \o "1-3" \h \z \u |
生成目录 |
| 当前页码 | PAGE |
第 X 页 |
| 总页数 | NUMPAGES |
整个文档页数 |
| 节页数 | SECTIONPAGES |
当前节页数 |
| 日期 | DATE \@ "yyyy-MM-dd" |
当前日期 |
如有问题或建议,请联系:
文档结束