feat: parse docx blocks during upload

This commit is contained in:
zwt13703
2026-07-01 20:29:31 +08:00
parent 95c507536a
commit 389b9c0c92
5 changed files with 147 additions and 54 deletions
+6 -2
View File
@@ -2,7 +2,8 @@ from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from sqlalchemy.orm import Session
from ..database import get_db
from ..services.template_service import create_template_record, store_template_file
from ..services.doc_parser import parse_document
from ..services.template_service import create_template_record, save_template_blocks, store_template_file
router = APIRouter(prefix="/api/templates", tags=["templates"])
@@ -17,8 +18,10 @@ def upload_template(
):
"""上传 Word 模板并创建模板记录。"""
try:
stored_path, _ = store_template_file(file)
stored_path, content = store_template_file(file)
blocks = parse_document(content)
template = create_template_record(db, name=name, file_path=stored_path, type=type)
save_template_blocks(db, template.id, blocks)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
@@ -29,6 +32,7 @@ def upload_template(
"data": {
"template_id": template.id,
"name": template.name,
"block_count": len(blocks),
"status": "uploaded",
},
"message": "ok",
+94 -41
View File
@@ -1,56 +1,92 @@
"""Word 文档解析引擎"""
from io import BytesIO
from typing import Any, Dict, Iterable, List
from docx import Document
from docx.paragraph import Paragraph
from docx.document import Document as DocxDocument
from docx.oxml.table import CT_Tbl
from docx.oxml.text.paragraph import CT_P
from docx.table import Table as DocxTable
from typing import List, Dict, Any
from docx.table import _Cell
from docx.text.paragraph import Paragraph
def parse_document(file_path: str) -> List[Dict[str, Any]]:
def parse_document(source: str | bytes) -> List[Dict[str, Any]]:
"""
解析 Word 文档,返回 block 列表(不含表格数据,仅标记位置)
每个 block 包含:type, text, level, style
解析 Word 文档,返回带 block_id、parent_block_id、sort_order 的 block 列表。
"""
doc = Document(file_path)
blocks = []
doc = Document(BytesIO(source)) if isinstance(source, bytes) else Document(source)
blocks: List[Dict[str, Any]] = []
for item in iter_block_items(doc):
if isinstance(item, Paragraph):
block = classify_paragraph(item)
else:
block = classify_table(item)
for para in doc.paragraphs:
block = classify_paragraph(para)
if block:
blocks.append(block)
assign_block_ids(blocks)
build_tree(blocks)
return blocks
def iter_block_items(parent: DocxDocument | _Cell) -> Iterable[Paragraph | DocxTable]:
"""按 Word 文档中的实际顺序遍历段落和表格。"""
parent_element = parent.element.body if isinstance(parent, DocxDocument) else parent._tc
for child in parent_element.iterchildren():
if isinstance(child, CT_P):
yield Paragraph(child, parent)
elif isinstance(child, CT_Tbl):
yield DocxTable(child, parent)
def classify_paragraph(para: Paragraph) -> Dict[str, Any] | None:
"""识别段落类型"""
text = para.text.strip()
style_name = para.style.name if para.style else ""
if not text and "Heading" not in style_name:
if not text:
return None # 跳过空段落
if "Heading" in style_name:
level = int(style_name.replace("Heading", "").strip()) if style_name.replace("Heading", "").strip().isdigit() else 1
if is_heading_style(style_name):
level = parse_heading_level(style_name)
return {"type": "heading", "text": text, "level": level, "style": style_name}
else:
return {"type": "paragraph", "text": text, "level": 0, "style": style_name}
def parse_tables(doc: Document) -> List[Dict[str, Any]]:
"""解析 Word 表格"""
tables = []
for i, table in enumerate(doc.tables):
headers = [cell.text.strip() for cell in table.rows[0].cells] if table.rows else []
tables.append({
def is_heading_style(style_name: str) -> bool:
return style_name.startswith("Heading") or style_name.startswith("标题")
def parse_heading_level(style_name: str) -> int:
parts = style_name.replace("Heading", "").replace("标题", "").strip()
return int(parts) if parts.isdigit() and 1 <= int(parts) <= 6 else 1
def classify_table(table: DocxTable) -> Dict[str, Any] | None:
"""识别表格类型并提取行列数。"""
rows = len(table.rows)
cols = len(table.columns)
if rows == 0 or cols == 0:
return None
first_row = [cell.text.strip() for cell in table.rows[0].cells]
text = " | ".join(cell for cell in first_row if cell)
if not text:
text = f"表格({rows} 行 x {cols} 列)"
return {
"type": "table",
"text": " | ".join(headers),
"text": text,
"level": 0,
"rows": len(table.rows),
"cols": len(table.columns),
"headers": headers,
"table_index": i,
})
return tables
"rows": rows,
"cols": cols,
"headers": first_row,
}
def generate_block_id(index: int) -> str:
@@ -58,29 +94,46 @@ def generate_block_id(index: int) -> str:
return f"block_{index:03d}"
def build_tree(blocks: List[Dict]) -> Dict:
def assign_block_ids(blocks: List[Dict[str, Any]]) -> None:
for index, block in enumerate(blocks, start=1):
block["block_id"] = generate_block_id(index)
block["sort_order"] = index
block["parent_block_id"] = None
def build_tree(blocks: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""构建层级树结构"""
tree = []
stack = [] # 存父级节点
tree: List[Dict[str, Any]] = []
heading_stack: List[Dict[str, Any]] = []
for block in blocks:
node = {"block_id": block["block_id"], "text": block["text"][:50], "children": []}
level = block.get("level", 0)
node = {
"block_id": block["block_id"],
"block_type": block["type"],
"text": block["text"][:50],
"level": block.get("level", 0),
"children": [],
}
# 回退栈
while stack and stack[-1]["level"] >= level:
stack.pop()
if block["type"] == "heading":
level = block.get("level", 1)
if stack:
parent = stack[-1]["node"]
while heading_stack and heading_stack[-1]["level"] >= level:
heading_stack.pop()
if heading_stack:
parent = heading_stack[-1]["node"]
parent["children"].append(node)
block["parent_block_id"] = parent["block_id"]
else:
tree.append(node)
block["parent_block_id"] = None
# 非叶子节点入栈
if level < 2 or block["type"] == "heading":
stack.append({"level": level, "node": node})
heading_stack.append({"level": level, "node": node})
elif heading_stack:
parent = heading_stack[-1]["node"]
parent["children"].append(node)
block["parent_block_id"] = parent["block_id"]
else:
tree.append(node)
return {"blocks": tree}
return tree
+24
View File
@@ -6,6 +6,7 @@ from uuid import uuid4
from fastapi import UploadFile
from sqlalchemy.orm import Session
from ..models.template import Template
from ..models.template_block import TemplateBlock
from .storage import upload_file
@@ -52,3 +53,26 @@ def create_template_record(db: Session, name: str, file_path: str, type: str = "
db.commit()
db.refresh(tmpl)
return tmpl
def save_template_blocks(db: Session, template_id: int, blocks: list[dict]) -> list[TemplateBlock]:
"""批量保存解析出的模板区域。"""
records = [
TemplateBlock(
template_id=template_id,
block_id=block["block_id"],
parent_block_id=block.get("parent_block_id"),
block_type=block["type"],
block_name=block.get("text", "")[:200] or None,
text_preview=block.get("text", "")[:500] or None,
level=block.get("level", 0),
sort_order=block.get("sort_order", index),
table_rows=block.get("rows"),
table_cols=block.get("cols"),
)
for index, block in enumerate(blocks, start=1)
]
db.add_all(records)
db.commit()
return records
@@ -32,13 +32,13 @@
| 017 | 实现 template 表 insert | 后端 | 0.25d | ✅ 已完成 |
| 018 | 组装上传接口 `POST /api/templates/upload` | 后端 | 0.25d | ✅ 已完成 |
| | **Word 解析** | | | |
| 019 | 实现 docx 文件打开 + 逐段落遍历 | 后端 | 0.25d | ⏳ 未完成 |
| 020 | 实现标题识别(Heading 1-6)→ type=heading | 后端 | 0.25d | ⏳ 未完成 |
| 021 | 实现段落识别 → type=paragraph | 后端 | 0.25d | ⏳ 未完成 |
| 022 | 实现表格识别 + 行数列数提取 → type=table | 后端 | 0.25d | ⏳ 未完成 |
| 023 | 实现 block_id 生成器(顺序编号) | 后端 | 0.25d | ⏳ 未完成 |
| 024 | 实现层级构建(parent_block_id+ 结构树 tree 输出 | 后端 | 0.25d | ⏳ 未完成 |
| 025 | 将解析结果批量写入 template_block 表 | 后端 | 0.25d | ⏳ 未完成 |
| 019 | 实现 docx 文件打开 + 逐段落遍历 | 后端 | 0.25d | ✅ 已完成 |
| 020 | 实现标题识别(Heading 1-6)→ type=heading | 后端 | 0.25d | ✅ 已完成 |
| 021 | 实现段落识别 → type=paragraph | 后端 | 0.25d | ✅ 已完成 |
| 022 | 实现表格识别 + 行数列数提取 → type=table | 后端 | 0.25d | ✅ 已完成 |
| 023 | 实现 block_id 生成器(顺序编号) | 后端 | 0.25d | ✅ 已完成 |
| 024 | 实现层级构建(parent_block_id+ 结构树 tree 输出 | 后端 | 0.25d | ✅ 已完成 |
| 025 | 将解析结果批量写入 template_block 表 | 后端 | 0.25d | ✅ 已完成 |
| | **HTML 预览生成** | | | |
| 026 | 标题 → `<h2 data-block-id>` 转换 + 居中加粗样式 | 后端 | 0.25d | ⏳ 未完成 |
| 027 | 段落 → `<p data-block-id>` 转换 + 首行缩进样式 | 后端 | 0.25d | ⏳ 未完成 |
+12
View File
@@ -23,3 +23,15 @@
4. 复用并完善文件校验、MinIO/本地降级存储与 template 表插入逻辑。
5. 执行 Python 语法检查与 FastAPI 路由导入检查,确认 `/api/templates/upload` 已注册。
- **执行结果**: 完成模板上传接口基础链路,任务 015-018 已在任务清单中标注为已完成。
## 会话 ID: 20260701-doc-parser
- [2026-07-01 20:29:17]
- **执行原因**: 按任务清单继续完成 Word 解析阶段 019-025。
- **执行过程**:
1. 重写 Word block 遍历逻辑,按文档 XML 原始顺序同时遍历段落和表格。
2. 实现 Heading 1-6/标题 1-6 识别、普通段落识别、表格行列数和表头预览提取。
3. 为解析结果顺序生成 `block_001` 形式的 `block_id``sort_order`
4. 基于标题层级计算 `parent_block_id`,并输出树结构节点。
5. 新增 `save_template_blocks`,上传模板后批量写入 `template_block`
6. 使用临时 docx 验证标题、段落、表格混排时的顺序和父子关系。
- **执行结果**: 完成任务 019-025,上传模板时会解析 Word 并保存模板区域块。