feat: parse docx blocks during upload
This commit is contained in:
@@ -2,7 +2,8 @@ from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from ..database import get_db
|
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"])
|
router = APIRouter(prefix="/api/templates", tags=["templates"])
|
||||||
@@ -17,8 +18,10 @@ def upload_template(
|
|||||||
):
|
):
|
||||||
"""上传 Word 模板并创建模板记录。"""
|
"""上传 Word 模板并创建模板记录。"""
|
||||||
try:
|
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)
|
template = create_template_record(db, name=name, file_path=stored_path, type=type)
|
||||||
|
save_template_blocks(db, template.id, blocks)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -29,6 +32,7 @@ def upload_template(
|
|||||||
"data": {
|
"data": {
|
||||||
"template_id": template.id,
|
"template_id": template.id,
|
||||||
"name": template.name,
|
"name": template.name,
|
||||||
|
"block_count": len(blocks),
|
||||||
"status": "uploaded",
|
"status": "uploaded",
|
||||||
},
|
},
|
||||||
"message": "ok",
|
"message": "ok",
|
||||||
|
|||||||
@@ -1,56 +1,92 @@
|
|||||||
"""Word 文档解析引擎"""
|
"""Word 文档解析引擎"""
|
||||||
|
from io import BytesIO
|
||||||
|
from typing import Any, Dict, Iterable, List
|
||||||
|
|
||||||
from docx import Document
|
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 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 列表(不含表格数据,仅标记位置)。
|
解析 Word 文档,返回带 block_id、parent_block_id、sort_order 的 block 列表。
|
||||||
每个 block 包含:type, text, level, style
|
|
||||||
"""
|
"""
|
||||||
doc = Document(file_path)
|
doc = Document(BytesIO(source)) if isinstance(source, bytes) else Document(source)
|
||||||
blocks = []
|
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:
|
if block:
|
||||||
blocks.append(block)
|
blocks.append(block)
|
||||||
|
|
||||||
|
assign_block_ids(blocks)
|
||||||
|
build_tree(blocks)
|
||||||
return 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:
|
def classify_paragraph(para: Paragraph) -> Dict[str, Any] | None:
|
||||||
"""识别段落类型"""
|
"""识别段落类型"""
|
||||||
text = para.text.strip()
|
text = para.text.strip()
|
||||||
style_name = para.style.name if para.style else ""
|
style_name = para.style.name if para.style else ""
|
||||||
|
|
||||||
if not text and "Heading" not in style_name:
|
if not text:
|
||||||
return None # 跳过空段落
|
return None # 跳过空段落
|
||||||
|
|
||||||
if "Heading" in style_name:
|
if is_heading_style(style_name):
|
||||||
level = int(style_name.replace("Heading", "").strip()) if style_name.replace("Heading", "").strip().isdigit() else 1
|
level = parse_heading_level(style_name)
|
||||||
return {"type": "heading", "text": text, "level": level, "style": style_name}
|
return {"type": "heading", "text": text, "level": level, "style": style_name}
|
||||||
else:
|
|
||||||
return {"type": "paragraph", "text": text, "level": 0, "style": style_name}
|
return {"type": "paragraph", "text": text, "level": 0, "style": style_name}
|
||||||
|
|
||||||
|
|
||||||
def parse_tables(doc: Document) -> List[Dict[str, Any]]:
|
def is_heading_style(style_name: str) -> bool:
|
||||||
"""解析 Word 表格"""
|
return style_name.startswith("Heading") or style_name.startswith("标题")
|
||||||
tables = []
|
|
||||||
for i, table in enumerate(doc.tables):
|
|
||||||
headers = [cell.text.strip() for cell in table.rows[0].cells] if table.rows else []
|
def parse_heading_level(style_name: str) -> int:
|
||||||
tables.append({
|
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",
|
"type": "table",
|
||||||
"text": " | ".join(headers),
|
"text": text,
|
||||||
"level": 0,
|
"level": 0,
|
||||||
"rows": len(table.rows),
|
"rows": rows,
|
||||||
"cols": len(table.columns),
|
"cols": cols,
|
||||||
"headers": headers,
|
"headers": first_row,
|
||||||
"table_index": i,
|
}
|
||||||
})
|
|
||||||
return tables
|
|
||||||
|
|
||||||
|
|
||||||
def generate_block_id(index: int) -> str:
|
def generate_block_id(index: int) -> str:
|
||||||
@@ -58,29 +94,46 @@ def generate_block_id(index: int) -> str:
|
|||||||
return f"block_{index:03d}"
|
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 = []
|
tree: List[Dict[str, Any]] = []
|
||||||
stack = [] # 存父级节点
|
heading_stack: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
for block in blocks:
|
for block in blocks:
|
||||||
node = {"block_id": block["block_id"], "text": block["text"][:50], "children": []}
|
node = {
|
||||||
level = block.get("level", 0)
|
"block_id": block["block_id"],
|
||||||
|
"block_type": block["type"],
|
||||||
|
"text": block["text"][:50],
|
||||||
|
"level": block.get("level", 0),
|
||||||
|
"children": [],
|
||||||
|
}
|
||||||
|
|
||||||
# 回退栈
|
if block["type"] == "heading":
|
||||||
while stack and stack[-1]["level"] >= level:
|
level = block.get("level", 1)
|
||||||
stack.pop()
|
|
||||||
|
|
||||||
if stack:
|
while heading_stack and heading_stack[-1]["level"] >= level:
|
||||||
parent = stack[-1]["node"]
|
heading_stack.pop()
|
||||||
|
|
||||||
|
if heading_stack:
|
||||||
|
parent = heading_stack[-1]["node"]
|
||||||
parent["children"].append(node)
|
parent["children"].append(node)
|
||||||
block["parent_block_id"] = parent["block_id"]
|
block["parent_block_id"] = parent["block_id"]
|
||||||
else:
|
else:
|
||||||
tree.append(node)
|
tree.append(node)
|
||||||
block["parent_block_id"] = None
|
|
||||||
|
|
||||||
# 非叶子节点入栈
|
heading_stack.append({"level": level, "node": node})
|
||||||
if level < 2 or block["type"] == "heading":
|
elif heading_stack:
|
||||||
stack.append({"level": level, "node": node})
|
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
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from uuid import uuid4
|
|||||||
from fastapi import UploadFile
|
from fastapi import UploadFile
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from ..models.template import Template
|
from ..models.template import Template
|
||||||
|
from ..models.template_block import TemplateBlock
|
||||||
from .storage import upload_file
|
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.commit()
|
||||||
db.refresh(tmpl)
|
db.refresh(tmpl)
|
||||||
return 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 | ✅ 已完成 |
|
| 017 | 实现 template 表 insert | 后端 | 0.25d | ✅ 已完成 |
|
||||||
| 018 | 组装上传接口 `POST /api/templates/upload` | 后端 | 0.25d | ✅ 已完成 |
|
| 018 | 组装上传接口 `POST /api/templates/upload` | 后端 | 0.25d | ✅ 已完成 |
|
||||||
| | **Word 解析** | | | |
|
| | **Word 解析** | | | |
|
||||||
| 019 | 实现 docx 文件打开 + 逐段落遍历 | 后端 | 0.25d | ⏳ 未完成 |
|
| 019 | 实现 docx 文件打开 + 逐段落遍历 | 后端 | 0.25d | ✅ 已完成 |
|
||||||
| 020 | 实现标题识别(Heading 1-6)→ type=heading | 后端 | 0.25d | ⏳ 未完成 |
|
| 020 | 实现标题识别(Heading 1-6)→ type=heading | 后端 | 0.25d | ✅ 已完成 |
|
||||||
| 021 | 实现段落识别 → type=paragraph | 后端 | 0.25d | ⏳ 未完成 |
|
| 021 | 实现段落识别 → type=paragraph | 后端 | 0.25d | ✅ 已完成 |
|
||||||
| 022 | 实现表格识别 + 行数列数提取 → type=table | 后端 | 0.25d | ⏳ 未完成 |
|
| 022 | 实现表格识别 + 行数列数提取 → type=table | 后端 | 0.25d | ✅ 已完成 |
|
||||||
| 023 | 实现 block_id 生成器(顺序编号) | 后端 | 0.25d | ⏳ 未完成 |
|
| 023 | 实现 block_id 生成器(顺序编号) | 后端 | 0.25d | ✅ 已完成 |
|
||||||
| 024 | 实现层级构建(parent_block_id)+ 结构树 tree 输出 | 后端 | 0.25d | ⏳ 未完成 |
|
| 024 | 实现层级构建(parent_block_id)+ 结构树 tree 输出 | 后端 | 0.25d | ✅ 已完成 |
|
||||||
| 025 | 将解析结果批量写入 template_block 表 | 后端 | 0.25d | ⏳ 未完成 |
|
| 025 | 将解析结果批量写入 template_block 表 | 后端 | 0.25d | ✅ 已完成 |
|
||||||
| | **HTML 预览生成** | | | |
|
| | **HTML 预览生成** | | | |
|
||||||
| 026 | 标题 → `<h2 data-block-id>` 转换 + 居中加粗样式 | 后端 | 0.25d | ⏳ 未完成 |
|
| 026 | 标题 → `<h2 data-block-id>` 转换 + 居中加粗样式 | 后端 | 0.25d | ⏳ 未完成 |
|
||||||
| 027 | 段落 → `<p data-block-id>` 转换 + 首行缩进样式 | 后端 | 0.25d | ⏳ 未完成 |
|
| 027 | 段落 → `<p data-block-id>` 转换 + 首行缩进样式 | 后端 | 0.25d | ⏳ 未完成 |
|
||||||
|
|||||||
@@ -23,3 +23,15 @@
|
|||||||
4. 复用并完善文件校验、MinIO/本地降级存储与 template 表插入逻辑。
|
4. 复用并完善文件校验、MinIO/本地降级存储与 template 表插入逻辑。
|
||||||
5. 执行 Python 语法检查与 FastAPI 路由导入检查,确认 `/api/templates/upload` 已注册。
|
5. 执行 Python 语法检查与 FastAPI 路由导入检查,确认 `/api/templates/upload` 已注册。
|
||||||
- **执行结果**: 完成模板上传接口基础链路,任务 015-018 已在任务清单中标注为已完成。
|
- **执行结果**: 完成模板上传接口基础链路,任务 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 并保存模板区域块。
|
||||||
|
|||||||
Reference in New Issue
Block a user