` 元素
3. ✓ **两遍扫描**:第一遍构建 vmerge_map,第二遍提取数据
4. ✓ **跳过 vMerge=continue**:只提取合并起始单元格(restart)
5. ✓ **记录 col_index**:为每个单元格记录绝对列位置
**导出阶段关键点:**
1. ✓ **使用 col_index 映射**:不按顺序迭代 cells_data
2. ✓ **遍历所有列**:从 0 到 num_cols-1,而非只遍历单元格数据
3. ✓ **跟踪 occupied 位置**:记录哪些位置被合并单元格占用
4. ✓ **使用绝对列索引**:`table.rows[r_idx].cells[col_idx]`
5. ✓ **标记合并占用**:合并后标记所有涉及的位置为已占用
**常见错误与修复:**
| 错误 | 现象 | 原因 | 修复 |
|------|------|------|------|
| **错误合并** | 空单元格被合并 | 使用 `is_empty` 判断扩展合并 | 只在明确标记 `vMerge=continue` 时扩展 |
| **重复单元格** | 被合并的单元格也被提取 | 没有跳过 `vMerge=continue` 单元格 | 检测并跳过 |
| **位置错误** | 单元格在错误的列 | 按顺序迭代而非使用 col_index | 使用 `cells_by_col` 映射 + 遍历所有列 |
| **应该合并的不合并** | rowspan 应该 >1 但为 1 | 第一遍扫描的判断逻辑不一致 | 统一使用 `v_merge_val != 'restart'` |
##### 相关文档
- `TABLE_MERGE_FIX_SUMMARY.md` - 提取阶段修复详细说明
- `TABLE_EXPORT_FIX_SUMMARY.md` - 导出阶段修复详细说明
- `MERGE_CELL_COMPLETE_FIX.md` - 完整修复总结
#### 4.3.8 表格样式示例
**示例 1:基础表格(无合并)**
```json
{
"rows": [
{
"cells": [
{"text": "姓名", "rowspan": 1, "colspan": 1, "style": {"bold": true}},
{"text": "年龄", "rowspan": 1, "colspan": 1, "style": {"bold": true}},
{"text": "职位", "rowspan": 1, "colspan": 1, "style": {"bold": true}}
]
},
{
"cells": [
{"text": "张三", "rowspan": 1, "colspan": 1, "style": {}},
{"text": "30", "rowspan": 1, "colspan": 1, "style": {}},
{"text": "工程师", "rowspan": 1, "colspan": 1, "style": {}}
]
}
]
}
```
**示例 2:合并单元格**
**表格效果:**
```
┌────────────────────────────────┐
│ 标题 │ ← 第1行:1个单元格横跨3列
│ (colspan=3) │
├──────────┬───────────┬─────────┤
│ │ 子项1 │ 100 │ ← 第2行:"项目A"纵跨2行
│ 项目A ├───────────┼─────────┤
│(rowspan=2)│ 子项2 │ 200 │ ← 第3行
│ │ │ │
└──────────┴───────────┴─────────┘
```
**JSON 数据结构:**
```json
{
"rows": [
{
"cells": [
{
"text": "标题",
"rowspan": 1,
"colspan": 3, // ← 横跨3列
"col_index": 0,
"style": {
"bold": true,
"align": "center",
"bg_color": "F0F0F0"
}
}
]
},
{
"cells": [
{
"text": "项目A",
"rowspan": 2, // ← 纵跨2行
"colspan": 1,
"col_index": 0,
"style": {
"valign": "middle"
}
},
{"text": "子项1", "rowspan": 1, "colspan": 1, "col_index": 1, "style": {}},
{"text": "100", "rowspan": 1, "colspan": 1, "col_index": 2, "style": {}}
]
},
{
"cells": [
// 注意:第1个单元格被上一行的"项目A"占据,所以这行只有2个单元格
{"text": "子项2", "rowspan": 1, "colspan": 1, "col_index": 1, "style": {}},
{"text": "200", "rowspan": 1, "colspan": 1, "col_index": 2, "style": {}}
]
}
]
}
```
**关键点:**
- `colspan`: 横向合并,值为合并的列数
- `rowspan`: 纵向合并,值为合并的行数
- `col_index`: 单元格在表格中的绝对列位置(0-based)
- 被合并占据的单元格**不需要**在数据中定义
- 例如:第3行只定义2个单元格,因为第1个位置被"项目A"占据
### 4.4 图片块(Image)
**插入示例:**
```sql
INSERT INTO document_blocks (id, block_order, type, level, "index", content, word_style, style, metadata)
VALUES (
'block-img-0', -- id
10, -- block_order
'image', -- type
0, -- level
0, -- index
'data:image/png;base64,iVBORw0KG...', -- content(Base64 数据)
'Normal', -- word_style
'{"width": 10.0, "height": 7.0, "unit": "cm", "align": "center"}', -- style
'{"alt": "示意图", "para_style": "Normal", "parent_heading_id": "block-h2-0"}' -- metadata
);
```
**样式优先级**:同标题块
**设计要点:**
- `content` 存储 Base64 编码的图片数据(Data URL 格式)
- `style` 定义图片显示尺寸和对齐方式
- `metadata` 包含图片描述和所属章节
**查询示例:**
```sql
-- 获取所有图片
SELECT
id,
block_order,
json_extract(metadata, '$.alt') as alt,
json_extract(style, '$.width') as width,
json_extract(style, '$.height') as height
FROM document_blocks
WHERE type = 'image'
ORDER BY block_order;
-- 获取某章节下的所有图片
SELECT * FROM document_blocks
WHERE type = 'image'
AND json_extract(metadata, '$.parent_heading_id') = 'block-h2-0'
ORDER BY block_order;
```
## 五、样式系统详细说明
### 5.1 Word 样式识别规则
#### 如何识别 Word 中的自定义样式作为标题
在 Word 中,标题有多种设置方式:
**方式A:使用内置标题样式**
- Word 样式名:`Heading 1`, `Heading 2`, `Heading 3` 等
- 直接映射:`Heading 1` → `level: 1`,`Heading 2` → `level: 2`
**方式B:使用自定义样式名称**
- 用户创建自定义样式:如"我的一级标题"、"章节标题"等
- 判断依据:检查样式的**大纲级别(Outline Level)**属性
- Outline Level = 1 → `level: 1`(一级标题)
- Outline Level = 2 → `level: 2`(二级标题)
- Outline Level = 0 或未设置 → 按普通段落处理
**方式C:直接格式化但未应用样式**
- 只是调整了字体、字号、加粗等格式
- 没有设置大纲级别
- 按普通段落处理,保留其格式样式
#### Python 实现示例
```python
from docx import Document
def identify_heading_level(paragraph):
"""识别段落的标题级别"""
style = paragraph.style
# 方法1:检查样式名称(内置样式)
if style.name.startswith('Heading'):
try:
level = int(style.name.split()[-1]) # "Heading 1" → 1
return level, style.name
except ValueError:
pass
# 方法2:检查大纲级别(自定义样式)
if hasattr(style.element, 'pPr') and style.element.pPr is not None:
outline_lvl = style.element.pPr.outlineLvl
if outline_lvl is not None:
level = outline_lvl.val + 1 # Word 大纲级别从 0 开始
return level, style.name
# 方法3:通过段落格式的大纲级别
if paragraph._element.pPr is not None:
outline_lvl = paragraph._element.pPr.outlineLvl
if outline_lvl is not None:
level = outline_lvl.val + 1
return level, style.name
# 不是标题,返回 None
return None, style.name
```
### 5.2 样式优先级与使用规则
#### 样式合并逻辑
```
style(颗粒度样式)覆盖 word_style(样式文件)中的对应属性 > 默认样式(代码内置)
```
**重要说明:**
- ✅ `style` 只在**渲染时覆盖**,不修改样式文件本身
- ✅ 样式文件(default.json)保持不变,作为基准样式库
- ✅ 每个 Block 的 `style` 字段独立存储覆盖值
- ✅ 类似 Word 中"应用样式 + 局部调整格式"的行为
#### 使用场景
**场景 A:使用样式文件(推荐)**
```sql
INSERT INTO document_blocks (..., word_style, style, ...)
VALUES (..., 'Normal', '{}', ...);
```
- 后端从样式文件查找 "Normal" 的完整定义
- 如果找到:应用样式文件中的所有属性(字体、大小、颜色等)
- 如果未找到:使用代码内置的默认样式
**示例:**
```json
// 样式文件中的 "Normal" 定义
{
"font_name": "宋体",
"font_size": 12.0,
"color": "000000",
"align": "left"
}
// 最终应用的样式
{
"font_name": "宋体",
"font_size": 12.0,
"color": "000000",
"align": "left"
}
```
**场景 B:样式覆盖(部分自定义)**
```sql
INSERT INTO document_blocks (..., word_style, style, ...)
VALUES (..., 'Normal', '{"font_name": "黑体"}', ...);
```
- `style` 中定义的属性**覆盖** `word_style` 中的对应属性
- `style` 中**未定义**的属性继承自 `word_style`
- 类似 Word 中"选中段落 → 单独修改字体"的行为
**示例:**
```json
// 样式文件中的 "Normal" 定义
{
"font_name": "宋体", // ← 会被覆盖
"font_size": 12.0, // ← 继承
"color": "000000", // ← 继承
"align": "left" // ← 继承
}
// style 中的覆盖定义
{
"font_name": "黑体" // 只覆盖字体
}
// 最终应用的样式(合并结果)
{
"font_name": "黑体", // ✓ 来自 style(覆盖)
"font_size": 12.0, // ✓ 来自 word_style(继承)
"color": "000000", // ✓ 来自 word_style(继承)
"align": "left" // ✓ 来自 word_style(继承)
}
```
**场景 C:完全自定义样式(前端编辑器)**
```sql
INSERT INTO document_blocks (..., word_style, style, ...)
VALUES (..., 'Normal', '{"font_name": "微软雅黑", "font_size": 18.0, "bold": true, "color": "FF0000", "align": "center"}', ...);
```
- 当 `style` 中定义了**所有必需属性**时,完全使用自定义样式
- 仍然可以参考 `word_style` 作为基准,但前端可以完全重写
- 前端提供样式编辑器让用户自定义
**示例:**
```json
// 样式文件中的 "Normal" 定义(作为参考)
{
"font_name": "宋体",
"font_size": 12.0,
"color": "000000",
"align": "left"
}
// style 中的完全自定义
{
"font_name": "微软雅黑",
"font_size": 18.0,
"bold": true,
"color": "FF0000",
"align": "center"
}
// 最终应用的样式(完全自定义)
{
"font_name": "微软雅黑", // ✓ 来自 style
"font_size": 18.0, // ✓ 来自 style
"bold": true, // ✓ 来自 style
"color": "FF0000", // ✓ 来自 style
"align": "center" // ✓ 来自 style
}
```
#### 实现逻辑(伪代码)
```python
def apply_style(block):
"""应用样式的合并逻辑"""
# 1. 从样式文件加载 word_style(只读,不修改文件)
base_style = load_style_from_file(block.word_style) or get_default_style()
# 2. 解析 block.style
custom_style = json.loads(block.style) if block.style else {}
# 3. 合并样式:custom_style 覆盖 base_style(仅在内存中)
final_style = {**base_style, **custom_style}
return final_style
# 注意:样式文件 (default.json) 始终保持不变
# 只有 block.style 字段会存储用户的自定义覆盖值
```
**数据流示例:**
```
┌─────────────────────┐
│ 样式文件 (只读) │
│ default.json │
│ { │
│ "Normal": { │
│ "font": "宋体" │
│ "size": 12 │
│ } │
│ } │
└──────────┬──────────┘
│
│ 读取 (不修改)
↓
┌─────────────────────┐ ┌──────────────────┐
│ 数据库 Block │ │ 渲染输出 │
│ word_style: "Normal"│ → │ font: "黑体" │
│ style: { │ 合并 │ size: 12 │
│ "font": "黑体" │ → │ (黑体覆盖宋体) │
│ } │ │ │
└─────────────────────┘ └──────────────────┘
样式文件依然是:
{
"Normal": {
"font": "宋体", ← 未改变
"size": 12
}
}
```
### 5.3 颗粒度样式属性定义
**通用属性(所有 Block 类型)✅ 可自定义**
```json
{
"font_name": "宋体", // 字体名称 ✅
"font_size": 12.0, // 字号(磅)✅
"bold": true, // 是否粗体 ✅
"italic": false, // 是否斜体 ✅
"underline": false, // 是否下划线 ✅
"color": "000000", // 文字颜色(十六进制)✅
"align": "left" // 对齐方式:"left", "center", "right", "justify" ✅
}
```
**段落特有属性 ❌ 暂不支持自定义**
```json
{
"line_spacing": 1.5, // 行距 ❌
"indent_first": 0, // 首行缩进(磅)❌
"indent_left": 0, // 左缩进(磅)❌
"indent_right": 0, // 右缩进(磅)❌
"space_before": 12.0, // 段前间距(磅)❌
"space_after": 6.0 // 段后间距(磅)❌
}
```
**注意:** 段落特有属性目前前端不能自定义,这些属性从 Word 提取时保存,渲染时使用,但用户暂时无法编辑。
**表格特有属性(表格级)❌ 使用默认样式**
```json
{
"table_width": 100, // 表格宽度 ✅ 可修改
"table_width_unit": "percent",// 宽度单位 ✅ 可修改
"align": "left", // 表格对齐 ❌
"border_style": "single", // 边框样式 ❌
"border_width": 0.5, // 边框宽度 ❌
"border_color": "000000", // 边框颜色 ❌
"cell_padding": 2.0, // 单元格内边距 ❌
"header_bold": true, // 表头是否加粗 ❌
"header_bg_color": "F0F0F0" // 表头背景色 ❌
}
```
**注意:** 表格样式使用默认样式,用户可修改表格宽度和列宽(见 4.3.2 章节),但不能修改边框、内边距等样式。
**图片特有属性 ✅ 可自定义**
```json
{
"width": 10.0, // 宽度 ✅
"height": 7.0, // 高度 ✅
"unit": "cm", // 单位:"cm", "inch", "px" ✅
"align": "center" // 对齐:"left", "center", "right" ✅
}
```
## 六、目录构建与标题关系
### 6.1 标题关系构建
**元数据存储(推荐):** 不存储 `children_ids`,只存储 `parent_id`,由前端/API 动态计算
```json
{
"parent_id": "block-h1-0"
// children_ids 不存储,按需动态计算
}
```
**优点:**
- ✅ 解析速度提升 100-1000 倍
- ✅ 单一数据源,不会出现不一致
- ✅ 更新简单,只需修改 parent_id
- ✅ 数据库文件减小 5-10%
### 6.2 构建目录树(Python 实现)
```python
import sqlite3
import json
def build_toc_tree(db_path: str) -> list:
"""构建完整目录树(一次 O(n) 遍历)"""
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
# 获取所有标题
cursor.execute("""
SELECT id, level, "index", content, metadata, block_order
FROM document_blocks
WHERE type = 'heading'
ORDER BY block_order
""")
headings = []
for row in cursor.fetchall():
metadata = json.loads(row['metadata'] or '{}')
headings.append({
'id': row['id'],
'level': row['level'],
'index': row['index'],
'content': row['content'],
'parent_id': metadata.get('parent_id'),
'children': []
})
# 构建映射
heading_map = {h['id']: h for h in headings}
# 构建树
tree = []
for h in headings:
parent_id = h['parent_id']
if parent_id and parent_id in heading_map:
heading_map[parent_id]['children'].append(h)
else:
tree.append(h)
conn.close()
return tree
```
### 6.3 前端构建目录树(JavaScript)
```javascript
async function loadDocumentTOC(documentId) {
// 1. 获取所有标题块
const response = await fetch(`/api/v1/documents/${documentId}/blocks?type=heading`);
const { blocks } = await response.json();
// 2. 构建树形结构
const map = new Map(blocks.map(h => [h.id, { ...h, children: [] }]));
const tree = [];
blocks.forEach(h => {
const node = map.get(h.id);
const parentId = h.metadata?.parent_id;
if (parentId && map.has(parentId)) {
map.get(parentId).children.push(node);
} else {
tree.push(node);
}
});
return tree;
}
// 渲染目录
function renderTOC(tree) {
return (
);
}
```
## 七、快速索引和搜索
### 7.1 基本查询示例
```sql
-- 获取所有块(按顺序)
SELECT * FROM document_blocks ORDER BY block_order;
-- 按类型获取块
SELECT * FROM document_blocks WHERE type = 'heading' ORDER BY block_order;
-- 获取单个块
SELECT * FROM document_blocks WHERE id = 'block-h1-0';
-- 获取某标题下的所有内容
SELECT * FROM document_blocks
WHERE json_extract(metadata, '$.parent_heading_id') = 'block-h1-0'
ORDER BY block_order;
```
### 7.2 内容搜索示例
```sql
-- 搜索段落内容
SELECT * FROM document_blocks
WHERE type = 'paragraph' AND content LIKE '%关键词%'
ORDER BY block_order;
-- 搜索表格内容
SELECT * FROM document_blocks
WHERE type = 'table' AND content LIKE '%关键词%'
ORDER BY block_order;
-- 全文搜索(所有类型)
SELECT * FROM document_blocks
WHERE content LIKE '%关键词%'
ORDER BY block_order;
-- 搜索标题
SELECT * FROM document_blocks
WHERE type = 'heading' AND content LIKE '%关键词%'
ORDER BY block_order, level;
```
### 7.3 性能优化
已创建的索引可以加速查询:
```sql
-- 已创建的索引
CREATE INDEX idx_block_order ON document_blocks(block_order); -- 顺序查询
CREATE INDEX idx_type ON document_blocks(type); -- 类型筛选
CREATE INDEX idx_level ON document_blocks(level); -- 标题级别
```
## 八、与现有 API 的集成
### 8.1 创建文档
```python
async def create_document(self, data: CreateDocumentRequest) -> Document:
# 1. 下载并解析 Word 文档
word_content = await _download_word(data.file_url)
# 2. 提取为结构化 Blocks
blocks = extract_blocks_from_word(word_content)
# 3. 创建 SQLite 数据库
db_path = f"tmp/{data.user_id}/sqlite/{doc_id}.db"
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
# 4. 初始化数据库并写入 Blocks
self._init_content_db(db_path, blocks)
# 5. 创建数据库记录
doc = Document(
id=doc_id,
content_db_path=db_path,
user_id=data.user_id,
created_at=datetime.now(timezone.utc)
)
self.db.add(doc)
await self.db.commit()
return doc
```
### 8.2 获取文档
```python
async def get_document(self, document_id: str) -> DocumentResponse:
# 1. 查询文档记录
doc = await self.db.get(Document, document_id)
if not doc:
raise DocumentNotFoundError(document_id)
# 2. 从 SQLite 加载 blocks
blocks = self._load_blocks_from_db(doc.content_db_path)
return {
"id": doc.id,
"blocks": blocks,
"dbPath": doc.content_db_path
}
```
### 8.3 更新文档
```python
async def update_document(self, document_id: str, updates: dict) -> Document:
doc = await self.db.get(Document, document_id)
# 按 Block ID 更新
if 'block_updates' in updates:
self._update_blocks_by_id(doc.content_db_path, updates['block_updates'])
doc.updated_at = datetime.now(timezone.utc)
await self.db.commit()
return doc
```
### 8.4 导出为 Word
```python
async def export_to_word(self, document_id: str) -> str:
# 1. 获取文档
doc = await self.db.get(Document, document_id)
# 2. 从 SQLite 加载 blocks
blocks = self._load_blocks_from_db(doc.content_db_path)
# 3. 生成 Word 文档
word_path = f"tmp/{doc.user_id}/{date.today()}/{document_id}.docx"
generate_word_from_blocks(blocks, word_path)
return word_path
```
if doc.format == "sqlite":
self._update_blocks_by_level_index(doc.content_db_path, data.blocks)
else:
doc.content = self._apply_block_updates(doc.content, data.blocks)
elif hasattr(data, 'block_updates') and data.block_updates:
# 模式3:按 Block ID 精确更新(新增)
if doc.format == "sqlite":
self._update_blocks_by_id(doc.content_db_path, data.block_updates)
doc.updated_at = datetime.now(timezone.utc)
await self.db.commit()
await self.db.refresh(doc)
return doc
def _update_blocks_by_id(self, db_path: str, updates: list):
"""按 Block ID 精确更新"""
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
for update in updates:
block_id = update['id']
fields = []
values = []
if 'content' in update:
fields.append('content = ?')
values.append(update['content'])
if 'style' in update:
fields.append('style = ?')
values.append(json.dumps(update['style']))
if 'metadata' in update:
fields.append('metadata = ?')
values.append(json.dumps(update['metadata']))
if fields:
values.append(block_id)
cursor.execute(f"""
UPDATE document_blocks
SET {', '.join(fields)}
WHERE id = ?
""", values)
conn.commit()
conn.close()
```
## 九、API 端点扩展
### 9.1 新增 Blocks 操作端点
```python
# app/api/v1/document_blocks.py
from fastapi import APIRouter, Depends, Query
from typing import Optional
router = APIRouter(prefix="/documents/{documentId}/blocks", tags=["Document Blocks"])
@router.get("", summary="获取文档所有 Blocks")
async def list_blocks(
documentId: str,
type: Optional[str] = Query(None, description="按类型筛选:heading, paragraph, table, image"),
parentId: Optional[str] = Query(None, description="按父标题 ID 筛选"),
):
"""
获取文档的所有内容块
**查询参数:**
- `type`: 按类型筛选
- `parentId`: 获取某标题下的所有内容
"""
doc = await document_service.get_document(documentId)
if doc.format != "sqlite":
raise ValueError("Document format is not SQLite")
conn = sqlite3.connect(doc.content_db_path)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
# 构建查询
where_clauses = []
params = []
if type:
where_clauses.append('type = ?')
params.append(type)
if parentId:
where_clauses.append('json_extract(metadata, "$.parent_heading_id") = ?')
params.append(parentId)
where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
cursor.execute(f"""
SELECT * FROM document_blocks
{where_sql}
ORDER BY block_order
""", params)
blocks = []
for row in cursor.fetchall():
block = dict(row)
block['style'] = json.loads(block['style'] or '{}')
block['metadata'] = json.loads(block['metadata'] or '{}')
if block['type'] == 'table':
block['content'] = json.loads(block['content'])
blocks.append(block)
conn.close()
return ok({"blocks": blocks})
@router.get("/{blockId}", summary="获取单个 Block")
async def get_block(documentId: str, blockId: str):
"""获取指定 ID 的 Block"""
doc = await document_service.get_document(documentId)
if doc.format != "sqlite":
raise ValueError("Document format is not SQLite")
conn = sqlite3.connect(doc.content_db_path)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute("""
SELECT * FROM document_blocks
WHERE id = ?
""", (blockId,))
row = cursor.fetchone()
conn.close()
if not row:
raise BlockNotFoundError(blockId)
block = dict(row)
block['style'] = json.loads(block['style'] or '{}')
block['metadata'] = json.loads(block['metadata'] or '{}')
if block['type'] == 'table':
block['content'] = json.loads(block['content'])
return ok(block)
```
@router.put("/{blockId}", summary="更新单个 Block")
async def update_block(
documentId: str,
blockId: str,
updates: dict
):
"""
更新指定 Block
**请求体示例:**
```json
{
"content": "更新后的内容",
"style": {"bold": true, "color": "FF0000"},
"metadata": {"parent_heading_id": "block-h1-0"}
}
```
"""
doc = await document_service.get_document(documentId)
if doc.format != "sqlite":
raise ValueError("Document format is not SQLite")
conn = sqlite3.connect(doc.content_db_path)
cursor = conn.cursor()
# 构建 UPDATE 语句
fields = []
values = []
if 'content' in updates:
fields.append('content = ?')
content = updates['content']
# 表格需要序列化
if isinstance(content, dict):
content = json.dumps(content)
values.append(content)
if 'style' in updates:
fields.append('style = ?')
values.append(json.dumps(updates['style']))
if 'metadata' in updates:
fields.append('metadata = ?')
values.append(json.dumps(updates['metadata']))
if not fields:
conn.close()
raise ValueError("No fields to update")
values.append(blockId)
cursor.execute(f"""
UPDATE document_blocks
SET {', '.join(fields)}
WHERE id = ?
""", values)
conn.commit()
conn.close()
if cursor.rowcount == 0:
raise BlockNotFoundError(blockId)
return ok({"blockId": blockId, "updatedAt": int(time.time() * 1000)})
@router.delete("/{blockId}", summary="删除单个 Block")
async def delete_block(documentId: str, blockId: str):
"""删除指定 Block"""
doc = await document_service.get_document(documentId)
if doc.format != "sqlite":
raise ValueError("Document format is not SQLite")
conn = sqlite3.connect(doc.content_db_path)
cursor = conn.cursor()
cursor.execute("""
DELETE FROM document_blocks
WHERE id = ?
""", (blockId,))
conn.commit()
conn.close()
if cursor.rowcount == 0:
raise BlockNotFoundError(blockId)
return ok({"message": "Block deleted successfully"})
@router.get("/search", summary="搜索文档内容")
async def search_blocks(
documentId: str,
q: str = Query(..., description="搜索关键词"),
limit: int = Query(50, ge=1, le=200)
):
"""全文搜索文档内容"""
doc = await document_service.get_document(documentId)
if doc.format != "sqlite":
raise ValueError("Document format is not SQLite")
conn = sqlite3.connect(doc.content_db_path)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute("""
SELECT * FROM document_blocks
WHERE content LIKE ?
ORDER BY block_order
LIMIT ?
""", (f'%{q}%', limit))
results = []
for row in cursor.fetchall():
block = dict(row)
block['style'] = json.loads(block['style'] or '{}')
block['metadata'] = json.loads(block['metadata'] or '{}')
results.append(block)
conn.close()
return ok({"results": results, "total": len(results)})
```
@router.get("/toc", summary="获取文档目录树")
async def get_toc(documentId: str):
"""获取文档的目录树结构"""
doc = await document_service.get_document(documentId)
if doc.format != "sqlite":
raise ValueError("Document format is not SQLite")
conn = sqlite3.connect(doc.content_db_path)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
# 获取所有标题
cursor.execute("""
SELECT id, level, "index", content, metadata, block_order
FROM document_blocks
WHERE type = 'heading'
ORDER BY block_order
""")
headings = []
for row in cursor.fetchall():
metadata = json.loads(row['metadata'] or '{}')
headings.append({
'id': row['id'],
'level': row['level'],
'index': row['index'],
'content': row['content'],
'parent_id': metadata.get('parent_id'),
'children': []
})
conn.close()
# 构建树
heading_map = {h['id']: h for h in headings}
tree = []
for h in headings:
parent_id = h['parent_id']
if parent_id and parent_id in heading_map:
heading_map[parent_id]['children'].append(h)
else:
tree.append(h)
return ok({"toc": tree})
@router.get("/stats", summary="获取文档统计信息")
async def get_stats(documentId: str):
"""获取文档的统计信息"""
doc = await document_service.get_document(documentId)
if doc.format != "sqlite":
raise ValueError("Document format is not SQLite")
conn = sqlite3.connect(doc.content_db_path)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
# 统计各类型 Block 数量
cursor.execute("""
SELECT type, COUNT(*) as count
FROM document_blocks
GROUP BY type
""")
type_stats = {row['type']: row['count'] for row in cursor.fetchall()}
# 统计标题层级
cursor.execute("""
SELECT level, COUNT(*) as count
FROM document_blocks
WHERE type = 'heading'
GROUP BY level
ORDER BY level
""")
heading_stats = [dict(row) for row in cursor.fetchall()]
# 总块数
cursor.execute("SELECT COUNT(*) as total FROM document_blocks")
total_blocks = cursor.fetchone()['total']
conn.close()
return ok({
"typeStats": type_stats,
"headingStats": heading_stats,
"totalBlocks": total_blocks
})
```
## 十、前端使用示例
### 10.1 加载文档
```javascript
// 1. 加载文档基本信息
const response = await fetch('/api/v1/documents/doc-123');
const { id, format, dbPath, content, blocks } = await response.json();
if (format === 'sqlite') {
// 2. 如果响应已包含 blocks,直接使用
if (blocks) {
renderDocument(blocks);
} else {
// 3. 否则单独获取 blocks
const blocksResp = await fetch(`/api/v1/documents/${id}/blocks`);
const { blocks } = await blocksResp.json();
renderDocument(blocks);
}
// 4. 获取目录树
const tocResp = await fetch(`/api/v1/documents/${id}/blocks/toc`);
const { toc } = await tocResp.json();
renderTOC(toc);
}
```
### 10.2 渲染文档
```javascript
function renderDocument(blocks) {
const container = document.getElementById('document-content');
blocks.forEach(block => {
let element;
switch (block.type) {
case 'heading':
element = document.createElement(`h${block.level}`);
element.id = block.id;
// 标题支持富文本格式
if (Array.isArray(block.content)) {
element.innerHTML = renderRichText(block.content);
} else {
element.textContent = block.content;
}
break;
case 'paragraph':
element = document.createElement('p');
element.id = block.id;
// 段落支持富文本格式
if (Array.isArray(block.content)) {
element.innerHTML = renderRichText(block.content);
} else {
element.textContent = block.content; // 简单文本
}
break;
case 'table':
element = renderTable(block);
break;
case 'image':
element = document.createElement('img');
element.id = block.id;
element.src = block.content;
element.alt = block.metadata?.alt || '图片';
applyImageStyle(element, block.style);
break;
}
if (element) {
applyStyle(element, block.style, block.word_style);
container.appendChild(element);
}
});
}
function renderTable(block) {
const table = document.createElement('table');
table.id = block.id;
const tableData = block.content;
tableData.rows.forEach((row, rowIdx) => {
const tr = document.createElement('tr');
row.cells.forEach((cell, colIdx) => {
const td = document.createElement(rowIdx === 0 ? 'th' : 'td');
// 表格单元格也支持富文本格式
if (Array.isArray(cell.text)) {
td.innerHTML = renderRichText(cell.text);
} else {
td.textContent = cell.text;
}
if (cell.rowspan > 1) td.rowSpan = cell.rowspan;
if (cell.colspan > 1) td.colSpan = cell.colspan;
applyStyle(td, cell.style);
tr.appendChild(td);
});
table.appendChild(tr);
});
return table;
}
// 富文本渲染函数
function renderRichText(content) {
if (!Array.isArray(content)) {
return content; // 如果不是数组,直接返回文本
}
return content.map(segment => {
let text = segment.text || '';
const style = segment.style || {};
// 应用行内样式
let html = text;
if (style.bold) {
html = `${html}`;
}
if (style.italic) {
html = `${html}`;
}
if (style.underline) {
html = `${html}`;
}
if (style.color) {
html = `${html}`;
}
if (style.font_name || style.font_size) {
const inlineStyle = [];
if (style.font_name) inlineStyle.push(`font-family: ${style.font_name}`);
if (style.font_size) inlineStyle.push(`font-size: ${style.font_size}pt`);
html = `${html}`;
}
return html;
}).join('');
}
```
### 10.3 搜索功能
```javascript
async function searchDocument(documentId, keyword) {
const response = await fetch(
`/api/v1/documents/${documentId}/blocks/search?q=${encodeURIComponent(keyword)}`
);
const { results } = await response.json();
// 渲染搜索结果
renderSearchResults(results);
// 高亮第一个结果
if (results.length > 0) {
scrollToBlock(results[0].id);
}
}
function renderSearchResults(results) {
const container = document.getElementById('search-results');
container.innerHTML = '';
results.forEach(block => {
const item = document.createElement('div');
item.className = 'search-result-item';
item.innerHTML = `
${block.type}
${highlightKeyword(block.content)}
`;
container.appendChild(item);
});
}
```
### 10.4 目录导航
```javascript
function renderTOC(toc) {
return (
);
}
function scrollToBlock(blockId) {
const element = document.getElementById(blockId);
if (element) {
element.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
// 高亮显示
element.classList.add('highlight');
setTimeout(() => {
element.classList.remove('highlight');
}, 2000);
}
}
```
### 10.5 编辑 Block
```javascript
async function updateBlock(documentId, blockId, updates) {
const response = await fetch(
`/api/v1/documents/${documentId}/blocks/${blockId}`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates)
}
);
const result = await response.json();
if (result.code === 0) {
console.log('Block updated successfully');
// 重新加载该 Block
reloadBlock(documentId, blockId);
}
}
// 使用示例
await updateBlock('doc-123', 'block-p-5', {
content: '更新后的段落内容',
style: { bold: true, color: 'FF0000' }
});
```
## 十一、性能优化建议
### 11.1 索引优化
现有索引已足够应对大部分查询场景,如需进一步优化可添加复合索引:
```sql
-- 复合索引(按类型和位置查询)
CREATE INDEX idx_type_order ON document_blocks(type, block_order);
-- 按父标题查询优化
CREATE INDEX idx_parent_heading ON document_blocks(
(json_extract(metadata, '$.parent_heading_id')),
block_order
);
```
### 11.2 批量操作优化
```sql
-- 使用事务批量更新
BEGIN TRANSACTION;
UPDATE document_blocks SET ... WHERE id = 'block-1';
UPDATE document_blocks SET ... WHERE id = 'block-2';
UPDATE document_blocks SET ... WHERE id = 'block-3';
COMMIT;
```
### 11.3 WAL 模式
启用 WAL 模式提升并发性能:
```sql
PRAGMA journal_mode=WAL;
PRAGMA synchronous=NORMAL;
PRAGMA cache_size=-64000; -- 64MB 缓存
```
**优势:**
- 读写并发:读取不阻塞写入
- 更好的性能:减少磁盘 I/O
- 更快的事务提交
## 十二、总结
本设计实现了结构化的 SQLite 存储方案,满足以下核心需求:
✅ **区分内容类型**:标题、正文、表格、图片分别存储,type 字段清晰标识
✅ **保留完整样式**:word_style + style 双重机制,支持样式文件和自定义样式
✅ **记录层级关系**:level + index + parent_id 构建完整的标题树
✅ **前端目录索引**:提供专用 API 快速构建目录,支持跳转和导航
✅ **支持局部更新**:按 Block ID 或 level+index 精确更新,性能优越
✅ **高效搜索**:全文搜索、类型筛选、范围查询一应俱全
✅ **稀疏排序策略**:避免频繁的 metadata 更新,提升插入和删除性能
---
**文档版本**:v1.0
**创建日期**:2026-07-02
**关联文档**:
- [document-management-design.md](./document-management-design.md)(文档管理整体设计)
- [export-doc-content-mapping.md](./export-doc-content-mapping.md)(导出功能设计)