# Content SQLite 设计说明 ## 一、文件结构概述 `content.db` 是一个 SQLite 数据库文件,每个文档对应一个独立的数据库文件,存储文档中的所有**内容块(Block)**。 ### 1.1 存储位置 ``` tmp/ ├── {user_id}/ │ ├── {YYYY-MM-DD}/ │ │ └── doc-abc123.doc # 导出的 Word 文件 │ └── sqlite/ │ └── doc-abc123.db # 文档内容数据库(SQLite) └── default.json # 默认样式文件 ``` **路径说明:** - Word 导出文件:`tmp/{user_id}/{YYYY-MM-DD}/{filename}.doc`(按日期分区) - SQLite 数据库:`tmp/{user_id}/sqlite/{document_id}.db`(统一存放) ### 1.2 与 documents 表的关系 ```python # documents 表字段 class Document(Base): __tablename__ = "documents" id: Mapped[str] = mapped_column(String(64), primary_key=True) content_db_path: Mapped[str] = mapped_column(String(1024)) # SQLite 数据库路径 # ... 其他字段 ``` ### 1.3 为什么选择 SQLite? | 特性 | 优势 | |------|------| | **结构化存储** | 原生支持,无需解析 | | **查询性能** | O(log n) 索引查询 | | **局部更新** | 直接 UPDATE,无需全量更新 | | **全文搜索** | 支持 FTS5 全文索引 | | **关系查询** | 原生 JOIN 支持 | | **数据完整性** | 外键约束、事务支持 | ## 二、数据库表结构 ### 2.1 document_blocks 表(内容块主表) ```sql CREATE TABLE document_blocks ( -- 主键 id TEXT PRIMARY KEY, -- Block 唯一 ID,如 "block-h1-0" -- 位置 block_order INTEGER NOT NULL, -- 在文档中的绝对位置(用于排序) -- 类型 type TEXT NOT NULL, -- Block 类型:heading, paragraph, table, image -- 标题信息 level INTEGER NOT NULL DEFAULT 0, -- 标题级别(1-6)或段落级别(0) "index" INTEGER NOT NULL DEFAULT 0, -- 该级别在文档中的序号 -- 内容 content TEXT, -- 文本内容(标题、段落)或结构化数据(表格、图片) -- 样式 word_style TEXT, -- Word 样式名称,如 "Heading 1", "Normal" style TEXT, -- 颗粒度样式 JSON(自定义样式时使用) -- 元数据 metadata TEXT -- 其他元数据 JSON ); -- 索引 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); ``` **字段说明:** | 字段 | 类型 | 说明 | 示例 | |------|------|------|------| | `id` | TEXT | Block 唯一标识 | `"block-h1-0"`, `"block-p-5"`, `"block-table-0"` | | `block_order` | INTEGER | 文档中的绝对位置,用于排序 | `1`, `2`, `3`, ... | | `type` | TEXT | Block 类型 | `"heading"`, `"paragraph"`, `"table"`, `"image"` | | `level` | INTEGER | 标题级别(1-6)或 0(非标题) | `1` (H1), `2` (H2), `0` (段落) | | `index` | INTEGER | 该级别标题的序号 | 第 2 个 H2 标题的 index 为 `1` | | `content` | TEXT | 内容存储(格式因类型而异) | 标题/段落:纯文本;表格:JSON;图片:Base64 | | `word_style` | TEXT | Word 样式名 | `"Heading 1"`, `"Normal"`, `"Table Grid"` | | `style` | TEXT | 自定义样式 JSON | `"{}"` 或 `'{"bold": true, "color": "FF0000"}'` | | `metadata` | TEXT | 元数据 JSON | `'{"parent_id": "block-h1-0"}'` | ## 三、稀疏排序与 Metadata 更新策略 ### 3.1 设计原理 为了在插入或删除 Block 时避免大量更新 `block_order`、`index` 等字段,采用**稀疏排序**策略。 **传统方案的问题:** - 初始排序:`1, 2, 3, 4, 5` - 在位置 2 后插入:需要将 `3, 4, 5` 全部更新为 `4, 5, 6` - 删除位置 3:需要将 `4, 5` 全部更新为 `3, 4` - 大文档会导致大量数据库更新操作 **稀疏排序方案:** - 初始排序:`100, 200, 300, 400, 500`(间隔 100) - 在位置 200 后插入:插入 `150`,无需更新其他 Block - 再次插入:可以插入 `125, 175` 等,填充间隙 - 删除操作:直接删除,无需更新其他 Block ### 3.2 实现示例 #### 初始化文档 ```sql -- 初始文档结构(间隔 100) INSERT INTO document_blocks (id, block_order, type, level, "index", content, ...) VALUES ('block-h1-0', 0, 'heading', 1, 0, '第一章', ...), ('block-p-0', 100, 'paragraph', 0, 0, '段落1', ...), ('block-p-1', 200, 'paragraph', 0, 0, '段落2', ...), ('block-table-0', 300, 'table', 0, 0, '{"rows": [...]}', ...), ('block-h1-1', 400, 'heading', 1, 1, '第二章', ...); ``` #### 插入新 Block ```sql -- 在 block_order=200 后插入段落 -- 计算中间值:(200 + 300) / 2 = 250 INSERT INTO document_blocks (id, block_order, type, level, "index", content, ...) VALUES ('block-p-new', 250, 'paragraph', 0, 0, '新段落', ...); -- 结果:0, 100, 200, 250(新), 300, 400 -- 无需更新其他 Block ``` #### 连续插入 ```sql -- 继续在 200 和 250 之间插入 INSERT INTO document_blocks (...) VALUES (..., 225, ...); -- 结果:0, 100, 200, 225(新), 250, 300, 400 ``` #### 间隙耗尽时的重排 当某个区间的间隙用尽时(例如无法再在 200-201 之间插入),触发**局部重排**: **场景示例:** ``` 原始序列:200, 201, 202, 300 需要在 200 和 201 之间插入 → 间隙不足(200.5 无法使用整数) 触发局部重排(重新分配 200-300 区间): - 区间内有 3 个 Block(201, 202, 新Block),区间大小 = 100 - 重新分配:220, 240, 260 - 结果:200, 220(原201), 240(原202), 260(新), 300 继续可插入:200, 210(新), 220, 230(新), 240, 250(新), 260, 270(新), 300 ``` **重排策略:** - **触发条件**:当前后两个 `block_order` 差值 ≤ 1 时 - **重排范围**:仅重排问题区间(通常 20-50 个 Block),不影响整个文档 - **重排算法**:在区间内均匀重新分配 block_order 值 - **性能影响**:局部重排操作远小于传统方案的全量更新 ### 3.3 标题 index 的稀疏处理 标题的 `index` 字段也可以采用稀疏排序: ```sql -- 初始标题(间隔 100) INSERT INTO document_blocks (id, type, level, "index", ...) VALUES ('block-h2-0', 'heading', 2, 0, ...), -- index: 0 ('block-h2-100', 'heading', 2, 100, ...), -- index: 100 ('block-h2-200', 'heading', 2, 200, ...); -- index: 200 -- 在 index=0 和 index=100 之间插入新标题 INSERT INTO document_blocks (id, type, level, "index", ...) VALUES ('block-h2-50', 'heading', 2, 50, ...); -- 结果:index: 0, 50(新), 100, 200 ``` ### 3.4 优势总结 | 操作 | 传统方案 | 稀疏排序方案 | |------|---------|------------| | **插入** | 更新所有后续 Block | 仅插入 1 条记录 | | **删除** | 更新所有后续 Block | 仅删除 1 条记录 | | **性能** | O(n) 更新操作 | O(1) 插入/删除 | | **数据库压力** | 高(大量 UPDATE) | 低(单条 INSERT/DELETE) | | **适用场景** | 小文档(<100 Block) | 大文档(1000+ Block) | ### 3.5 何时触发重排 - **插入时**:当间隙 ≤ 1 时,对局部区间(20-50 个 Block)重排 - **定期维护**:可选,定期对整个文档重排以恢复均匀间隔 - **导出时**:导出为 Word 时重新计算连续排序,不影响数据库 ## 四、Block 类型说明 ### 4.0 Metadata 更新策略 #### 性能问题 如果在 `metadata` 中同时存储 `parent_id` 和 `children_ids`,会导致严重的性能问题: **问题示例:** ```json { "parent_id": "block-h1-0", "children_ids": ["block-h2-0", "block-h2-100", "block-h2-200"] // ❌ 不推荐 } ``` **性能问题:** - 每添加一个标题,都要遍历已有的所有 blocks - 时间复杂度:O(n²),其中 n 是 blocks 数量 - 对于 1000 个 blocks 的文档,需要约 500,000 次比较 - 对于 10000 个 blocks 的文档,需要约 50,000,000 次比较 #### 推荐方案 **只存储 `parent_id`,不存储 `children_ids`**: ```json { "parent_id": "block-h1-0" // ✓ 不存储 children_ids,按需动态计算 } ``` **优势:** - ✅ 插入/删除操作时间复杂度:O(1) - ✅ 无需更新其他 blocks 的 metadata - ✅ 数据一致性高,单一数据源 - ✅ 前端/API 可通过 SQL 查询动态构建子节点列表 **动态查询子节点:** ```sql -- 查询某标题的所有子标题 SELECT * FROM document_blocks WHERE type = 'heading' AND json_extract(metadata, '$.parent_id') = 'block-h1-0' ORDER BY block_order; ``` ### 4.1 标题块(Heading) #### 基础插入示例 **简单文本(无富文本格式):** ```sql INSERT INTO document_blocks (id, block_order, type, level, "index", content, word_style, style, metadata) VALUES ( 'block-h1-0', -- id 100, -- block_order 'heading', -- type 1, -- level 0, -- index '基本数据', -- content(纯文本) 'Heading 1', -- word_style '{}', -- style(空表示使用 word_style) '{"parent_id": null}' -- metadata ); ``` #### 富文本内容(部分样式覆盖) 当标题或段落中**部分文字需要不同样式**时(如部分加粗、变色),使用 JSON 数组格式: **示例 1:标题中部分文字变色** ```sql INSERT INTO document_blocks (id, block_order, type, level, "index", content, word_style, style, metadata) VALUES ( 'block-h1-1', 200, 'heading', 1, 100, '[ {"text": "基本", "style": {}}, {"text": "数据", "style": {"color": "FF0000", "bold": true}} ]', -- content(富文本 JSON) 'Heading 1', '{}', '{"parent_id": null}' ); ``` **渲染结果:** "基本数据" **示例 2:标题中多段样式** ```sql -- 标题:"第一章 概述与分析"("概述与分析"为红色) INSERT INTO document_blocks (..., content, ...) VALUES (..., '[ {"text": "第一章 ", "style": {}}, {"text": "概述与分析", "style": {"color": "FF0000"}} ]', ... ); ``` **示例 3:复杂富文本组合** ```sql -- 标题:"项目名称:东河塘油田"("东河塘油田"加粗且下划线) INSERT INTO document_blocks (..., content, ...) VALUES (..., '[ {"text": "项目名称:", "style": {}}, {"text": "东河塘油田", "style": {"bold": true, "underline": true}} ]', ... ); ``` #### Content 字段格式说明 `content` 字段支持两种格式: **格式 1:纯文本字符串** ```sql content = '基本数据' ``` - 适用于:整个标题/段落使用统一样式 - 样式来源:`word_style` + `style`(Block 级别) **格式 2:富文本 JSON 数组** ```json [ { "text": "文本片段1", "style": {} // 继承 Block 级别样式 }, { "text": "文本片段2", "style": {"color": "FF0000", "bold": true} // 覆盖特定属性 } ] ``` - 适用于:标题/段落中部分文字需要不同样式 - 每个片段独立定义样式覆盖 - 未定义的样式属性继承自 Block 级别的 `word_style` + `style` #### 样式继承层级 ``` 样式文件 (word_style) ↓ Block 级别样式 (style) ↓ 文本片段样式 (content[].style) ``` **完整示例:** ```sql -- Block 定义 INSERT INTO document_blocks (id, block_order, type, level, "index", content, word_style, style, metadata) VALUES ( 'block-h1-2', 300, 'heading', 1, 200, '[ {"text": "关于", "style": {}}, {"text": "重要", "style": {"color": "FF0000", "bold": true}}, {"text": "通知", "style": {"font_size": 16.0}} ]', 'Heading 1', -- 样式文件中定义:宋体、14pt、黑色 '{"font_name": "微软雅黑"}', -- Block 覆盖:改为微软雅黑 '{"parent_id": null}' ); ``` **渲染逻辑:** 1. 样式文件 "Heading 1":`{font_name: "宋体", font_size: 14.0, color: "000000"}` 2. Block 覆盖:`{font_name: "微软雅黑"}` → 字体变为微软雅黑 3. 文本片段: - "关于":微软雅黑、14pt、黑色(继承) - "重要":微软雅黑、14pt、**红色、粗体**(覆盖) - "通知":微软雅黑、**16pt**、黑色(覆盖字号) #### 样式优先级规则 1. **纯文本模式**:`word_style` → Block `style` 2. **富文本模式**:`word_style` → Block `style` → 片段 `style` **设计要点:** - `level` + `index` 对应现有 API 的局部更新逻辑 - `metadata` 中的 `parent_id` 构建标题树,便于生成目录 - `block_order` 使用稀疏排序保证文档块的顺序 - `content` 支持纯文本或富文本 JSON 格式 **查询示例:** ```sql -- 获取所有一级标题 SELECT * FROM document_blocks WHERE type = 'heading' AND level = 1 ORDER BY block_order; -- 获取标题树(使用 parent_id) SELECT b1.id, b1.level, b1."index", b1.content, json_extract(b1.metadata, '$.parent_id') as parent_id FROM document_blocks b1 WHERE b1.type = 'heading' ORDER BY b1.block_order; -- 获取某标题下的所有内容块 SELECT * FROM document_blocks WHERE json_extract(metadata, '$.parent_heading_id') = 'block-h1-0' ORDER BY block_order; ``` ### 4.2 段落块(Paragraph) #### 基础插入示例 **方式 1:简单文本(无富文本格式)** ```sql INSERT INTO document_blocks (id, block_order, type, level, "index", content, word_style, style, metadata) VALUES ( 'block-p-0', -- id 200, -- block_order 'paragraph', -- type 0, -- level(段落为 0) 0, -- index '东河塘油田东河1区块DH1-10H井', -- content(纯文本) 'Normal', -- word_style '{}', -- style '{"parent_heading_id": "block-h1-0"}' -- metadata ); ``` **方式 2:富文本 JSON 格式(推荐)** ```sql INSERT INTO document_blocks (id, block_order, type, level, "index", content, word_style, style, metadata) VALUES ( 'block-p-1', 300, 'paragraph', 0, 0, '[ {"text": "东河塘油田", "style": {"bold": true}}, {"text": "东河1区块DH1-10H井", "style": {}} ]', -- content(富文本 JSON) 'Normal', '{}', '{"parent_heading_id": "block-h1-0"}' ); ``` #### 富文本段落示例 **示例 1:部分文字加粗和变色** ```sql -- 段落:"项目位置在陕西省,投资金额约5000万元" -- 效果:"陕西省" 红色加粗,"5000万元" 蓝色加粗 INSERT INTO document_blocks (..., content, ...) VALUES (..., '[ {"text": "项目位置在", "style": {}}, {"text": "陕西省", "style": {"bold": true, "color": "FF0000"}}, {"text": ",投资金额约", "style": {}}, {"text": "5000万元", "style": {"bold": true, "color": "0000FF"}} ]', ... ); ``` **示例 2:混合样式段落** ```sql -- 复杂格式:普通文字 + 加粗 + 斜体 + 下划线 INSERT INTO document_blocks (..., content, ...) VALUES (..., '[ {"text": "根据", "style": {}}, {"text": "《安全生产法》", "style": {"bold": true, "underline": true}}, {"text": "第23条规定,企业应当", "style": {}}, {"text": "定期检查", "style": {"italic": true, "color": "FF0000"}}, {"text": "设备运行状态。", "style": {}} ]', ... ); ``` #### Content 格式对比 | 格式 | 适用场景 | 优点 | 缺点 | |------|---------|------|------| | **纯文本** | 统一样式段落 | 简洁、数据量小 | 无法部分样式调整 | | **富文本 JSON** | 复杂样式、精确控制 | 样式灵活、精确 | 数据量稍大 | **纯文本与富文本对照:** ```sql -- 纯文本格式(整段统一样式) content = '东河塘油田东河1区块' -- 富文本格式(部分文字加粗) content = '[ {"text": "东河塘油田", "style": {"bold": true}}, {"text": "东河1区块", "style": {}} ]' ``` **设计要点:** - 支持纯文本格式(简单)和富文本 JSON(灵活性) - `metadata.parent_heading_id` 关联所属章节 - 富文本格式支持更精细的样式控制(颜色、字号、下划线等) **查询示例:** ```sql -- 搜索段落内容 SELECT * FROM document_blocks WHERE type = 'paragraph' AND content LIKE '%东河塘%' ORDER BY block_order; -- 获取某章节下的所有段落 SELECT * FROM document_blocks WHERE type = 'paragraph' AND json_extract(metadata, '$.parent_heading_id') = 'block-h2-0' ORDER BY block_order; ``` ### 4.3 表格块(Table) #### 4.3.1 表格样式与内容样式 **样式层级:** 1. **表格样式(默认)**:存储在 Block 的 `style` 字段 - 表格边框样式、表格宽度等 - 通常使用默认样式,不常修改 2. **单元格内容样式(可修改)**:存储在每个单元格的 `style` 字段 - 文本样式(加粗、颜色、字号等) - 单元格样式(背景色、对齐等) - **从 Word 提取时自动检测并保存** 3. **合并单元格与尺寸**:存储在 `metadata` 和单元格属性中 - `rowspan`、`colspan`:单元格合并 - `table_width`、`col_widths`:表格和列宽 #### 4.3.2 基础表格结构 表格的 `content` 字段存储 JSON 格式的结构化数据: **简单表格示例:** ```json { "rows": [ { "cells": [ { "text": "设计单位名称", "rowspan": 1, "colspan": 1, "style": {"bold": true} // ← Word 提取时检测到加粗 }, { "text": "东河采油气管理区", "rowspan": 1, "colspan": 3, "style": {} } ] }, { "cells": [ { "text": "审核意见", "rowspan": 2, "colspan": 1, "style": {"bold": true, "valign": "middle"} // ← 加粗 + 垂直居中 }, { "text": "说明:...", "rowspan": 1, "colspan": 1, "style": {} } ] } ] } ``` **插入示例:** ```sql INSERT INTO document_blocks (id, block_order, type, level, "index", content, word_style, style, metadata) VALUES ( 'block-table-0', -- id 500, -- block_order 'table', -- type 0, -- level 0, -- index '{"rows": [...]}', -- content(JSON 格式) 'Table Grid', -- word_style(表格样式名) '{}', -- style(使用默认表格样式) '{"cols": 4, "rows": 6, "table_width": 100, "table_width_unit": "percent", "col_widths": [25, 25, 25, 25], "parent_heading_id": "block-h2-0"}' ); ``` **Metadata 字段说明:** | 字段 | 类型 | 说明 | 示例 | |------|------|------|------| | `cols` | number | 表格列数 | `4` | | `rows` | number | 表格行数 | `6` | | `table_width` | number | 表格宽度值 | `100` | | `table_width_unit` | string | 表格宽度单位 | `"percent"`, `"cm"`, `"inch"` | | `col_widths` | array | 每列宽度 | `[25, 25, 25, 25]` | | `row_heights` | array | 每行高度(厘米) | `[1.0, 0.8, 0.8]` | | `parent_heading_id` | string | 所属章节 ID | `"block-h2-0"` | #### 表格宽度定义 **表格总宽度单位:** ```json // 示例 1:百分比宽度(占页面宽度的 100%) { "table_width": 100, "table_width_unit": "percent" } // 示例 2:厘米宽度(固定 16 厘米) { "table_width": 16, "table_width_unit": "cm" } // 示例 3:英寸宽度(固定 6.5 英寸) { "table_width": 6.5, "table_width_unit": "inch" } ``` #### 列宽定义(col_widths) **规则:** - 数组长度必须等于列数(`cols`) - 当 `table_width_unit` 为 `"percent"` 时,值表示百分比 - 当 `table_width_unit` 为 `"cm"` 或 `"inch"` 时,值表示绝对宽度 **示例 1:百分比列宽(等宽)** ```json { "cols": 4, "table_width": 100, "table_width_unit": "percent", "col_widths": [25, 25, 25, 25] // 每列占 25%,总和 = 100% } ``` **示例 2:百分比列宽(不等宽)** ```json { "cols": 4, "table_width": 100, "table_width_unit": "percent", "col_widths": [40, 30, 20, 10] // 总和 = 100% } ``` **示例 3:绝对列宽(厘米)** ```json { "cols": 3, "table_width": 15, "table_width_unit": "cm", "col_widths": [5, 5, 5] // 每列 5 厘米,总和 = 15cm } ``` #### 行高定义(row_heights) **规则:** - **可选字段**:如果不定义,行高自动适应内容 - 数组长度必须等于行数(`rows`) - 单位:**厘米(cm)** **示例 1:固定行高** ```json { "rows": 3, "row_heights": [1.5, 1.0, 1.0] // 第1行 1.5cm,第2、3行各 1.0cm } ``` **示例 2:不定义行高(自动适应)** ```json { "rows": 3 // 不定义 row_heights,行高根据内容自动调整 } ``` **示例 3:混合使用** ```json { "rows": 5, "row_heights": [2.0, 0.8, 0.8, 0.8, 1.5] // 第1行(表头)2.0cm // 第2-4行(数据)0.8cm // 第5行(总计)1.5cm } ``` #### 完整表格尺寸示例 ```json { "cols": 4, "rows": 3, "table_width": 100, "table_width_unit": "percent", "col_widths": [30, 25, 25, 20], // 列宽:30%, 25%, 25%, 20% "row_heights": [1.2, 0.8, 0.8], // 行高:1.2cm, 0.8cm, 0.8cm "parent_heading_id": "block-h2-0" } ``` #### 4.3.3 单元格内容格式 单元格的 `text` 字段支持两种格式: **格式 1:纯文本** ```json { "text": "设计单位名称", "rowspan": 1, "colspan": 1, "style": {"bold": true} // 整个单元格统一样式 } ``` **格式 2:富文本(单元格内部分样式)** ```json { "text": [ {"text": "设计单位", "style": {}}, {"text": "名称", "style": {"color": "FF0000"}} ], "rowspan": 1, "colspan": 1, "style": {"bold": true} // 单元格基础样式 } ``` **完整示例:表头加粗显示** ```sql INSERT INTO document_blocks (..., content, ...) VALUES (..., '{ "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": {}} ] } ] }', ... ); ``` **说明:** - ✅ **Word 提取时**:自动检测表头单元格是否加粗,保存到 `style.bold` - ✅ **前端渲染时**:根据 `style.bold` 渲染加粗效果 - ✅ **用户编辑时**:可修改单元格文本样式(字体、字号、加粗、颜色等)和对齐方式 #### 4.3.4 单元格(Cell)结构详解 ```typescript { "text": string | array, // 单元格内容:纯文本 或 富文本数组 "rowspan": number, // 垂直合并单元格数(默认1)✅ 可修改 "colspan": number, // 水平合并单元格数(默认1)✅ 可修改 "style": { // 单元格样式 // ===== 文本内容样式(可修改)===== "font_name": string, // 字体名称 ✅ "font_size": number, // 字号(磅)✅ "bold": boolean, // 是否粗体 ✅ ← Word 提取时检测 "italic": boolean, // 是否斜体 ✅ "underline": boolean, // 是否下划线 ✅ "color": string, // 文字颜色(十六进制)✅ "align": string, // 水平对齐:"left", "center", "right" ✅ "valign": string, // 垂直对齐:"top", "middle", "bottom" ✅ // ===== 单元格样式(目前不支持修改)===== } } ``` **可修改的属性:** - ✅ 单元格内容(`text`) - ✅ 合并单元格(`rowspan`、`colspan`) - ✅ 文本样式(字体、字号、加粗、斜体、下划线、颜色) - ✅ 文本对齐(水平对齐、垂直对齐) **暂不支持修改的属性:** - ❌ 单元格背景色(`bg_color`) - ❌ 单元格边框(`border_*`) - ❌ 单元格内边距(`padding`) **注意:** Word 提取时会检测并保存所有样式属性,但前端编辑器目前只支持修改文本内容样式和对齐方式。 #### 4.3.5 边框(Border)对象 **注意:** 单元格边框目前不支持修改,此定义仅供 Word 提取和系统内部使用。 #### 4.3.6 查询示例 ```sql -- 获取表格基本信息 SELECT id, json_extract(metadata, '$.cols') as cols, json_extract(metadata, '$.rows') as rows, json_extract(metadata, '$.table_width') as width FROM document_blocks WHERE type = 'table' ORDER BY block_order; -- 搜索表格中的内容(使用 JSON 函数) SELECT id, block_order, content FROM document_blocks WHERE type = 'table' AND content LIKE '%东河%' ORDER BY block_order; -- 统计表格数量 SELECT COUNT(*) as table_count FROM document_blocks WHERE type = 'table'; -- 获取包含特定列数的表格 SELECT * FROM document_blocks WHERE type = 'table' AND json_extract(metadata, '$.cols') = 4 ORDER BY block_order; ``` #### 4.3.7 表格样式示例 **示例 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列 "style": { "bold": true, "align": "center", "bg_color": "F0F0F0" } } ] }, { "cells": [ { "text": "项目A", "rowspan": 2, // ← 纵跨2行 "colspan": 1, "style": { "valign": "middle" } }, {"text": "子项1", "rowspan": 1, "colspan": 1, "style": {}}, {"text": "100", "rowspan": 1, "colspan": 1, "style": {}} ] }, { "cells": [ // 注意:第1个单元格被上一行的"项目A"占据,所以这行只有2个单元格 {"text": "子项2", "rowspan": 1, "colspan": 1, "style": {}}, {"text": "200", "rowspan": 1, "colspan": 1, "style": {}} ] } ] } ``` **关键点:** - `colspan`: 横向合并,值为合并的列数 - `rowspan`: 纵向合并,值为合并的行数 - 被合并占据的单元格**不需要**在数据中定义 - 例如:第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)(导出功能设计)