diff --git a/backend/database.py b/backend/database.py
index a409e87..5cb1226 100644
--- a/backend/database.py
+++ b/backend/database.py
@@ -26,9 +26,23 @@ async def init_db():
from models.document import Document
from models.generation_log import GenerationLog
from models.reference_file import ReferenceFile
+ from models.template_block import TemplateBlock
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
dialect_name = conn.dialect.name
+ # template_blocks 新增列
+ block_columns = await conn.run_sync(lambda sync_conn: [column["name"] for column in inspect(sync_conn).get_columns("template_blocks")])
+ if "anchor_start_index" not in block_columns:
+ await conn.execute(text("ALTER TABLE template_blocks ADD COLUMN anchor_start_index INT DEFAULT 0"))
+ if "anchor_end_index" not in block_columns:
+ await conn.execute(text("ALTER TABLE template_blocks ADD COLUMN anchor_end_index INT DEFAULT 0"))
+ if "html_snippet" not in block_columns:
+ await conn.execute(text("ALTER TABLE template_blocks ADD COLUMN html_snippet TEXT"))
+ # generation_logs 新增 block_id
+ log_columns = await conn.run_sync(lambda sync_conn: [column["name"] for column in inspect(sync_conn).get_columns("generation_logs")])
+ if "block_id" not in log_columns:
+ await conn.execute(text("ALTER TABLE generation_logs ADD COLUMN block_id INT DEFAULT NULL"))
+ await conn.execute(text("ALTER TABLE generation_logs MODIFY COLUMN paragraph_id INT DEFAULT NULL"))
columns = await conn.run_sync(lambda sync_conn: [column["name"] for column in inspect(sync_conn).get_columns("ai_models")])
if "supports_streaming" not in columns:
if dialect_name == "sqlite":
diff --git a/backend/models/generation_log.py b/backend/models/generation_log.py
index c4f2848..2c1719e 100644
--- a/backend/models/generation_log.py
+++ b/backend/models/generation_log.py
@@ -5,7 +5,8 @@ class GenerationLog(Base):
__tablename__ = "generation_logs"
id = Column(Integer, primary_key=True, autoincrement=True)
document_id = Column(Integer, ForeignKey("documents.id"), nullable=False)
- paragraph_id = Column(Integer, ForeignKey("paragraphs.id"), nullable=False)
+ paragraph_id = Column(Integer, ForeignKey("paragraphs.id"), nullable=True)
+ block_id = Column(Integer, ForeignKey("template_blocks.id"), nullable=True, comment="块级生成日志关联")
model_id = Column(Integer, ForeignKey("ai_models.id"), nullable=True)
status = Column(String(20), default="pending", comment="pending/generating/success/failed")
content = Column(Text, default="", comment="生成的内容")
diff --git a/backend/models/template_block.py b/backend/models/template_block.py
index 6bb7960..42bb21a 100644
--- a/backend/models/template_block.py
+++ b/backend/models/template_block.py
@@ -13,6 +13,9 @@ class TemplateBlock(Base):
sort_index = Column(Integer, default=0, comment="排序")
block_type = Column(String(30), default="text", comment="heading/text/table/ai_slot/variable")
anchor_ref = Column(String(500), default="", comment="原始锚点引用")
+ anchor_start_index = Column(Integer, default=0, comment="原始文档起始元素索引")
+ anchor_end_index = Column(Integer, default=0, comment="原始文档结束元素索引")
+ html_snippet = Column(Text, comment="渲染后的 HTML 片段")
title = Column(String(500), default="", comment="块标题")
content_json = Column(Text, default="{}", comment="块内容 JSON")
style_json = Column(Text, default="{}", comment="块样式 JSON")
diff --git a/backend/routers/export.py b/backend/routers/export.py
index 9d3e3ae..d78f921 100644
--- a/backend/routers/export.py
+++ b/backend/routers/export.py
@@ -15,7 +15,8 @@ from models.document import Document
from models.generation_log import GenerationLog
from models.paragraph import Paragraph
from models.template import Template
-from services.document_export import export_document_bytes
+from models.template_block import TemplateBlock
+from services.document_export import export_document_bytes, export_document_bytes_from_blocks
from services.minio_client import (
download_object_bytes,
get_presigned_url,
@@ -74,6 +75,78 @@ async def export_docx(document_id: int, db: AsyncSession = Depends(get_db)):
)
+@router.get("/{document_id}/docx-v2")
+async def export_docx_v2(document_id: int, db: AsyncSession = Depends(get_db)):
+ """基于块结构的 Word 导出。"""
+ document = await db.get(Document, document_id)
+ if document is None:
+ raise HTTPException(status_code=404, detail="生成记录不存在")
+
+ template = await db.get(Template, document.template_id)
+ if template is None:
+ raise HTTPException(status_code=404, detail="模板不存在")
+
+ template_bucket, template_object = split_bucket_path(template.file_path)
+ template_bytes = await asyncio.to_thread(download_object_bytes, template_bucket, template_object)
+
+ # 查询块和生成日志
+ blocks_result = await db.execute(
+ select(TemplateBlock)
+ .where(TemplateBlock.template_id == document.template_id)
+ .order_by(TemplateBlock.sort_index.asc(), TemplateBlock.id.asc())
+ )
+ blocks = blocks_result.scalars().all()
+
+ logs_result = await db.execute(
+ select(GenerationLog)
+ .where(GenerationLog.document_id == document_id)
+ )
+ logs = logs_result.scalars().all()
+
+ block_dicts = [
+ {
+ "id": b.id,
+ "sort_index": b.sort_index,
+ "anchor_start_index": b.anchor_start_index,
+ "anchor_end_index": b.anchor_end_index,
+ "edit_mode": b.edit_mode,
+ "block_type": b.block_type,
+ "title": b.title,
+ }
+ for b in blocks
+ ]
+
+ log_dicts = [
+ {
+ "id": log.id,
+ "block_id": log.block_id,
+ "status": log.status,
+ "content": json.loads(log.content) if log.content else {"content": []},
+ }
+ for log in logs
+ ]
+
+ exported_bytes = await asyncio.to_thread(
+ export_document_bytes_from_blocks, template_bytes, block_dicts, log_dicts
+ )
+
+ object_name = f"{datetime.now().strftime('%Y%m%d')}/{uuid.uuid4().hex}.docx"
+ await asyncio.to_thread(
+ upload_bytes,
+ settings.MINIO_BUCKET_OUTPUTS,
+ object_name,
+ exported_bytes,
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ )
+
+ document.file_path = f"{settings.MINIO_BUCKET_OUTPUTS}/{object_name}"
+ await db.commit()
+ return RedirectResponse(
+ url=get_presigned_url(settings.MINIO_BUCKET_OUTPUTS, object_name),
+ status_code=307,
+ )
+
+
@router.get("/{document_id}/pdf")
async def export_pdf(document_id: int):
return PlainTextResponse(
diff --git a/backend/routers/generate.py b/backend/routers/generate.py
index 58e680b..cc88b8b 100644
--- a/backend/routers/generate.py
+++ b/backend/routers/generate.py
@@ -1,6 +1,7 @@
import asyncio
import json
import os
+import time
import uuid
from datetime import datetime
@@ -17,14 +18,17 @@ from models.generation_log import GenerationLog
from models.paragraph import Paragraph
from models.reference_file import ReferenceFile
from models.template import Template
+from models.template_block import TemplateBlock
from schemas.schemas import GenerateFullRequest, GenerateTestRequest, ReferenceFileUpdate, Response
from services.ai_service import call_ai, stream_ai_preview
from services.file_summary import summarize_minio_files
from services.generation_runtime import (
build_mock_content,
+ build_mock_content_for_block,
generation_progress,
request_cancel,
run_generation,
+ run_generation_for_blocks,
update_progress,
)
from services.minio_client import delete_object, split_bucket_path, upload_bytes, get_presigned_url
@@ -368,6 +372,45 @@ async def generate_full(body: GenerateFullRequest, db: AsyncSession = Depends(ge
return Response(data=_serialize_document(document))
+@router.post("/full-v2")
+async def generate_full_v2(body: GenerateFullRequest, db: AsyncSession = Depends(get_db)):
+ """基于块结构的全量生成(可视化编辑器使用)。"""
+ template = await db.get(Template, body.template_id)
+ if template is None:
+ raise HTTPException(status_code=404, detail="模板不存在")
+
+ result = await db.execute(
+ select(TemplateBlock)
+ .where(TemplateBlock.template_id == body.template_id)
+ .order_by(TemplateBlock.sort_index.asc(), TemplateBlock.id.asc())
+ )
+ blocks = result.scalars().all()
+ if not blocks:
+ raise HTTPException(status_code=400, detail="模板下暂无可生成块")
+
+ ai_block_count = sum(1 for b in blocks if b.edit_mode == "ai")
+ if ai_block_count == 0:
+ raise HTTPException(status_code=400, detail="模板下没有 AI 生成块")
+
+ document = Document(
+ template_id=template.id,
+ name=f"{template.name}-{datetime.now().strftime('%Y%m%d%H%M%S')}",
+ para_count_done=0,
+ para_count_total=ai_block_count,
+ status="generating",
+ file_path="",
+ error="",
+ request_payload_json=json.dumps({"template_name": template.name, "block_count": len(blocks), "ai_block_count": ai_block_count}, ensure_ascii=False),
+ )
+ db.add(document)
+ await db.flush()
+ await db.commit()
+ await db.refresh(document)
+ update_progress(document.id, status="pending", percent=0, done=0, total=ai_block_count, message="块级生成任务已创建")
+ asyncio.create_task(run_generation_for_blocks(document.id, template.id))
+ return Response(data=_serialize_document(document))
+
+
@router.get("/progress/{document_id}")
async def generate_progress(document_id: int):
async def event_generator():
@@ -387,6 +430,86 @@ async def generate_progress(document_id: int):
return EventSourceResponse(event_generator())
+@router.post("/blocks/{block_id}/regenerate")
+async def regenerate_block(
+ block_id: int,
+ body: GenerateTestRequest,
+ db: AsyncSession = Depends(get_db),
+):
+ """对单个块重新生成内容。"""
+ block = await db.get(TemplateBlock, block_id)
+ if block is None:
+ raise HTTPException(status_code=404, detail="块不存在")
+
+ document = await db.get(Document, body.template_id)
+ if document is None:
+ raise HTTPException(status_code=404, detail="生成记录不存在")
+
+ model = None
+ if body.model_id:
+ model = await db.get(AiModel, body.model_id)
+ elif block.model_id:
+ model = await db.get(AiModel, block.model_id)
+
+ if model is None or model.status != "enabled":
+ # 使用默认模型
+ result = await db.execute(
+ select(AiModel).where(AiModel.status == "enabled").order_by(AiModel.id.asc()).limit(1)
+ )
+ model = result.scalars().first()
+
+ content = {}
+ status = "success"
+ error_msg = ""
+ duration = 0.0
+
+ if model is None:
+ content = build_mock_content_for_block(block)
+ else:
+ start = time.perf_counter()
+ try:
+ temp_para = Paragraph(
+ template_id=block.template_id,
+ sort_index=block.sort_index,
+ title=block.title,
+ content="",
+ prompt_text=body.prompt_text or block.prompt_text or "",
+ output_format=block.output_format,
+ need_prompt=block.need_prompt,
+ edit_mode=block.edit_mode,
+ )
+ setattr(temp_para, "enable_reasoning", bool(model.enable_reasoning))
+ result_data = await call_ai(temp_para, model, None)
+ content = result_data.content
+ except Exception as error:
+ content = build_mock_content_for_block(block)
+ status = "failed"
+ error_msg = str(error)
+ duration = round(time.perf_counter() - start, 4)
+
+ log = GenerationLog(
+ document_id=document.id,
+ block_id=block.id,
+ model_id=model.id if model else None,
+ status=status,
+ content=json.dumps(content, ensure_ascii=False),
+ duration=duration,
+ error_msg=error_msg,
+ )
+ db.add(log)
+ await db.commit()
+ await db.refresh(log)
+
+ return Response(data={
+ "id": log.id,
+ "block_id": block.id,
+ "status": log.status,
+ "content": content,
+ "duration": log.duration,
+ "error_msg": log.error_msg,
+ })
+
+
@router.get("/documents")
async def list_documents(
page: int = Query(1, ge=1),
@@ -410,6 +533,7 @@ async def get_document(document_id: int, db: AsyncSession = Depends(get_db)):
if document is None:
raise HTTPException(status_code=404, detail="生成记录不存在")
+ # 查询段落级日志
log_result = await db.execute(
select(GenerationLog, Paragraph)
.join(Paragraph, Paragraph.id == GenerationLog.paragraph_id)
@@ -431,6 +555,52 @@ async def get_document(document_id: int, db: AsyncSession = Depends(get_db)):
payload = _serialize_document(document)
payload["logs"] = items
+
+ # 查询块级日志和模板块(用于可视化预览)
+ blocks_result = await db.execute(
+ select(TemplateBlock)
+ .where(TemplateBlock.template_id == document.template_id)
+ .order_by(TemplateBlock.sort_index.asc(), TemplateBlock.id.asc())
+ )
+ template_blocks = blocks_result.scalars().all()
+
+ block_logs_result = await db.execute(
+ select(GenerationLog)
+ .where(GenerationLog.document_id == document_id, GenerationLog.block_id.isnot(None))
+ )
+ block_logs = block_logs_result.scalars().all()
+
+ block_log_map: dict[int, dict] = {}
+ for log in block_logs:
+ if log.block_id is not None:
+ block_log_map[log.block_id] = {
+ "id": log.id,
+ "block_id": log.block_id,
+ "status": log.status,
+ "content": json.loads(log.content) if log.content else {"content": []},
+ "duration": log.duration,
+ "error_msg": log.error_msg,
+ }
+
+ payload["template_blocks"] = [
+ {
+ "id": b.id,
+ "sort_index": b.sort_index,
+ "block_type": b.block_type,
+ "anchor_start_index": b.anchor_start_index,
+ "anchor_end_index": b.anchor_end_index,
+ "html_snippet": b.html_snippet,
+ "title": b.title,
+ "edit_mode": b.edit_mode,
+ "output_format": b.output_format,
+ "model_id": b.model_id,
+ "prompt_text": b.prompt_text,
+ }
+ for b in template_blocks
+ ]
+ payload["block_logs"] = block_log_map
+ payload["has_blocks"] = len(template_blocks) > 0
+
return Response(data=payload)
@@ -460,3 +630,22 @@ async def delete_document(document_id: int, db: AsyncSession = Depends(get_db)):
await db.delete(document)
await db.commit()
return Response(data={"id": document_id})
+
+
+@router.delete("/blocks/{block_id}/reset/{document_id}")
+async def reset_block(block_id: int, document_id: int, db: AsyncSession = Depends(get_db)):
+ """删除块的生成日志,恢复为模板默认内容。"""
+ block = await db.get(TemplateBlock, block_id)
+ if block is None:
+ raise HTTPException(status_code=404, detail="块不存在")
+
+ result = await db.execute(
+ select(GenerationLog)
+ .where(GenerationLog.document_id == document_id, GenerationLog.block_id == block_id)
+ )
+ logs = result.scalars().all()
+ for log in logs:
+ await db.delete(log)
+ await db.commit()
+
+ return Response(data={"block_id": block_id, "deleted_logs": len(logs)})
diff --git a/backend/routers/templates.py b/backend/routers/templates.py
index 8651a10..b753218 100644
--- a/backend/routers/templates.py
+++ b/backend/routers/templates.py
@@ -1,4 +1,5 @@
import asyncio
+import json
import os
import tempfile
import uuid
@@ -15,9 +16,11 @@ from models.document import Document
from models.generation_log import GenerationLog
from models.paragraph import Paragraph
from models.template import Template
+from models.template_block import TemplateBlock
from schemas.schemas import Response, TemplateSave
from services.minio_client import minio_client
from services.template_parser import parse_template
+from services.word_to_html import docx_to_full_html, docx_to_html_blocks
router = APIRouter()
@@ -64,6 +67,32 @@ def _serialize_template(template: Template) -> dict:
}
+def _serialize_block(block: TemplateBlock) -> dict:
+ return {
+ "id": block.id,
+ "template_id": block.template_id,
+ "sort_index": block.sort_index,
+ "block_type": block.block_type,
+ "anchor_ref": block.anchor_ref,
+ "anchor_start_index": block.anchor_start_index,
+ "anchor_end_index": block.anchor_end_index,
+ "html_snippet": block.html_snippet,
+ "title": block.title,
+ "content_json": block.content_json,
+ "style_json": block.style_json,
+ "edit_mode": block.edit_mode,
+ "placeholder_key": block.placeholder_key,
+ "variable_key": block.variable_key,
+ "default_value": block.default_value,
+ "model_id": block.model_id,
+ "need_prompt": block.need_prompt,
+ "prompt_text": block.prompt_text,
+ "need_file": block.need_file,
+ "file_note": block.file_note,
+ "output_format": block.output_format,
+ }
+
+
@router.get("")
async def list_templates(
page: int = Query(1, ge=1),
@@ -106,6 +135,105 @@ async def get_template(template_id: int, db: AsyncSession = Depends(get_db)):
return Response(data=payload)
+@router.get("/{template_id}/preview")
+async def get_template_preview(template_id: int, db: AsyncSession = Depends(get_db)):
+ """返回模板的 HTML 预览文档及块列表。"""
+ template = await db.get(Template, template_id)
+ if template is None:
+ raise HTTPException(status_code=404, detail="模板不存在")
+
+ # 优先从缓存读取 HTML,否则重新生成
+ result = await db.execute(
+ select(TemplateBlock)
+ .where(TemplateBlock.template_id == template_id)
+ .order_by(TemplateBlock.sort_index.asc(), TemplateBlock.id.asc())
+ )
+ blocks = result.scalars().all()
+
+ if not blocks:
+ # 从 MinIO 下载模板文件,重新生成 HTML 和块
+ file_path = template.file_path or ""
+ if "/" in file_path:
+ bucket, object_name = file_path.split("/", 1)
+ content = await asyncio.to_thread(minio_client.get_object, bucket, object_name)
+ try:
+ file_bytes = content.read()
+ finally:
+ content.close()
+ content.release_conn()
+
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".docx") as temp_file:
+ temp_file.write(file_bytes)
+ temp_path = temp_file.name
+
+ try:
+ rendered_blocks = await asyncio.to_thread(docx_to_html_blocks, temp_path)
+ html_content = await asyncio.to_thread(docx_to_full_html, temp_path)
+ finally:
+ if os.path.exists(temp_path):
+ os.remove(temp_path)
+
+ block_rows: list[TemplateBlock] = []
+ for rb in rendered_blocks:
+ anchor_title = ""
+ if rb.block_type == "heading":
+ anchor_title = rb.text_content
+ block_row = TemplateBlock(
+ template_id=template_id,
+ sort_index=rb.block_index + 1,
+ block_type=rb.block_type,
+ anchor_ref=anchor_title,
+ anchor_start_index=rb.block_index,
+ anchor_end_index=rb.block_index,
+ html_snippet=rb.html,
+ title=rb.text_content[:200] if rb.text_content else "",
+ content_json=json.dumps({"text": rb.text_content}, ensure_ascii=False),
+ style_json=rb.style_json,
+ edit_mode="manual",
+ )
+ db.add(block_row)
+ block_rows.append(block_row)
+ await db.commit()
+ for br in block_rows:
+ await db.refresh(br)
+ blocks = block_rows
+
+ block_list = [_serialize_block(b) for b in blocks]
+
+ # 检查是否有缓存的 HTML 快照
+ html_snapshot = ""
+ for b in blocks:
+ if b.html_snippet:
+ html_snapshot += b.html_snippet + "\n"
+
+ if not html_snapshot:
+ # 重新生成 HTML
+ file_path = template.file_path or ""
+ if "/" in file_path:
+ bucket, object_name = file_path.split("/", 1)
+ content = await asyncio.to_thread(minio_client.get_object, bucket, object_name)
+ try:
+ file_bytes = content.read()
+ finally:
+ content.close()
+ content.release_conn()
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".docx") as temp_file:
+ temp_file.write(file_bytes)
+ temp_path = temp_file.name
+ try:
+ html_snapshot = await asyncio.to_thread(docx_to_full_html, temp_path)
+ finally:
+ if os.path.exists(temp_path):
+ os.remove(temp_path)
+
+ return Response(data={
+ "template": _serialize_template(template),
+ "blocks": block_list,
+ "html": html_snapshot,
+ "block_count": len(block_list),
+ })
+
+
@router.post("/upload")
async def upload_template(file: UploadFile = File(...), db: AsyncSession = Depends(get_db)):
if not file.filename:
@@ -127,6 +255,8 @@ async def upload_template(file: UploadFile = File(...), db: AsyncSession = Depen
try:
parsed_items = await asyncio.to_thread(parse_template, temp_path)
+ rendered_blocks = await asyncio.to_thread(docx_to_html_blocks, temp_path)
+ html_snapshot = await asyncio.to_thread(docx_to_full_html, temp_path)
finally:
if os.path.exists(temp_path):
os.remove(temp_path)
@@ -167,13 +297,37 @@ async def upload_template(file: UploadFile = File(...), db: AsyncSession = Depen
db.add(paragraph)
paragraph_rows.append(paragraph)
+ # 同步生成新版块结构
+ block_rows: list[TemplateBlock] = []
+ for rb in rendered_blocks:
+ anchor_title = rb.text_content if rb.block_type == "heading" else ""
+ block_row = TemplateBlock(
+ template_id=template.id,
+ sort_index=rb.block_index + 1,
+ block_type=rb.block_type,
+ anchor_ref=anchor_title,
+ anchor_start_index=rb.block_index,
+ anchor_end_index=rb.block_index,
+ html_snippet=rb.html,
+ title=rb.text_content[:200] if rb.text_content else "",
+ content_json=json.dumps({"text": rb.text_content}, ensure_ascii=False),
+ style_json=rb.style_json,
+ edit_mode="manual",
+ )
+ db.add(block_row)
+ block_rows.append(block_row)
+
await db.commit()
await db.refresh(template)
for paragraph in paragraph_rows:
await db.refresh(paragraph)
+ for br in block_rows:
+ await db.refresh(br)
payload = _serialize_template(template)
payload["paragraphs"] = [_serialize_paragraph(item) for item in paragraph_rows]
+ payload["blocks"] = [_serialize_block(b) for b in block_rows]
+ payload["html"] = html_snapshot
return Response(data=payload)
@@ -230,6 +384,86 @@ async def save_template_paragraphs(
return Response(data={"template_id": template_id, "saved": len(body.paragraphs)})
+@router.put("/{template_id}/blocks")
+async def save_template_blocks(
+ template_id: int,
+ body: dict,
+ db: AsyncSession = Depends(get_db),
+):
+ """保存模板的块配置(可视化编辑器使用)。"""
+ template = await db.get(Template, template_id)
+ if template is None:
+ raise HTTPException(status_code=404, detail="模板不存在")
+
+ block_data = body.get("blocks", [])
+ if not block_data:
+ raise HTTPException(status_code=400, detail="块数据不能为空")
+
+ result = await db.execute(
+ select(TemplateBlock)
+ .where(TemplateBlock.template_id == template_id)
+ )
+ existing_blocks = result.scalars().all()
+ block_map = {b.id: b for b in existing_blocks}
+ incoming_ids = {b.get("id") for b in block_data if b.get("id")}
+
+ # 删除被移除的块
+ for block in existing_blocks:
+ if block.id not in incoming_ids:
+ await db.delete(block)
+
+ # 新增 / 更新块
+ for index, config in enumerate(block_data, start=1):
+ block = block_map.get(config.get("id")) if config.get("id") else None
+ if block is None:
+ block = TemplateBlock(template_id=template_id)
+ db.add(block)
+ block.sort_index = index
+ block.block_type = config.get("block_type", block.block_type)
+ block.anchor_ref = config.get("anchor_ref", block.anchor_ref)
+ block.anchor_start_index = config.get("anchor_start_index", block.anchor_start_index)
+ block.anchor_end_index = config.get("anchor_end_index", block.anchor_end_index)
+ block.title = config.get("title", block.title)
+ block.content_json = config.get("content_json", block.content_json)
+ block.style_json = config.get("style_json", block.style_json)
+ block.edit_mode = config.get("edit_mode", block.edit_mode)
+ block.model_id = config.get("model_id", block.model_id)
+ block.need_prompt = config.get("need_prompt", block.need_prompt)
+ block.prompt_text = config.get("prompt_text", block.prompt_text)
+ block.need_file = config.get("need_file", block.need_file)
+ block.file_note = config.get("file_note", block.file_note)
+ block.output_format = config.get("output_format", block.output_format)
+
+ await db.commit()
+
+ # 更新每个块的 html_snippet 加入对应的 CSS class
+ try:
+ result = await db.execute(
+ select(TemplateBlock)
+ .where(TemplateBlock.template_id == template_id)
+ .order_by(TemplateBlock.sort_index.asc(), TemplateBlock.id.asc())
+ )
+ updated_blocks = result.scalars().all()
+ for b in updated_blocks:
+ if b.html_snippet and b.edit_mode == "ai":
+ b.html_snippet = b.html_snippet.replace(
+ 'data-block-index=',
+ 'class="block-ai" data-block-index=',
+ 1,
+ )
+ elif b.html_snippet and b.edit_mode == "manual":
+ b.html_snippet = b.html_snippet.replace(
+ 'data-block-index=',
+ 'class="block-fixed" data-block-index=',
+ 1,
+ )
+ await db.commit()
+ except Exception:
+ pass
+
+ return Response(data={"template_id": template_id, "saved": len(block_data)})
+
+
@router.delete("/{template_id}")
async def delete_template(template_id: int, db: AsyncSession = Depends(get_db)):
template = await db.get(Template, template_id)
@@ -240,6 +474,9 @@ async def delete_template(template_id: int, db: AsyncSession = Depends(get_db)):
paragraphs_to_delete = result.scalars().all()
paragraph_ids = [p.id for p in paragraphs_to_delete]
+ blocks_result = await db.execute(select(TemplateBlock).where(TemplateBlock.template_id == template_id))
+ blocks_to_delete = blocks_result.scalars().all()
+
doc_result = await db.execute(select(Document).where(Document.template_id == template_id))
documents_to_delete = doc_result.scalars().all()
@@ -256,6 +493,9 @@ async def delete_template(template_id: int, db: AsyncSession = Depends(get_db)):
for paragraph in paragraphs_to_delete:
await db.delete(paragraph)
+ for block in blocks_to_delete:
+ await db.delete(block)
+
file_path = template.file_path or ""
if "/" in file_path:
bucket, object_name = file_path.split("/", 1)
diff --git a/backend/services/document_export.py b/backend/services/document_export.py
index fdba938..1c8726b 100644
--- a/backend/services/document_export.py
+++ b/backend/services/document_export.py
@@ -388,3 +388,111 @@ def export_document_bytes(template_bytes: bytes, logs: list[dict]) -> bytes:
output = BytesIO()
document.save(output)
return output.getvalue()
+
+
+def export_document_bytes_from_blocks(template_bytes: bytes, blocks: list[dict], logs: list[dict]) -> bytes:
+ """基于块结构的文档导出,支持块排序。
+
+ blocks: TemplateBlock 序列化列表,每个包含 anchor_start_index、edit_mode、sort_index 等
+ logs: GenerationLog 序列化列表,每个包含 block_id、content、status
+ """
+ document = Document(BytesIO(template_bytes))
+
+ # 构建 block_id → log 映射
+ log_by_block_id: dict[int, dict] = {}
+ for log in logs:
+ block_id = log.get("block_id")
+ if block_id is not None:
+ log_by_block_id[block_id] = log
+
+ # 构建 block 映射
+ block_by_start_index: dict[int, dict] = {}
+ for block in blocks:
+ start_idx = block.get("anchor_start_index")
+ if start_idx is not None:
+ block_by_start_index[start_idx] = block
+
+ # 按 sort_index 排序的块列表
+ sorted_blocks = sorted(
+ [b for b in blocks if b.get("anchor_start_index") is not None],
+ key=lambda b: b.get("sort_index", 0),
+ )
+
+ # 枚举所有文档元素
+ all_items: list = list(_iter_block_items(document))
+
+ # 先对所有元素进行内容替换(在重排之前)
+ for index, item in enumerate(all_items):
+ block = block_by_start_index.get(index)
+ if block is None:
+ continue
+ if block.get("edit_mode") == "manual":
+ continue
+ log = log_by_block_id.get(block.get("id"))
+ if log is None or log.get("status") != "success":
+ continue
+ content = log.get("content") or {"content": []}
+ if isinstance(content, str):
+ import json
+ try:
+ content = json.loads(content)
+ except Exception:
+ content = {"content": [{"type": "text", "text": content}]}
+
+ if isinstance(item, Paragraph):
+ text_parts: list[str] = []
+ for c in content.get("content", []):
+ if c.get("type") == "text":
+ text_parts.append(c.get("text", ""))
+ new_text = "\n".join(text_parts)
+ if new_text:
+ _set_paragraph_text(item, new_text, template_paragraph=item)
+
+ # ---- 重排文档元素 ----
+ # 收集所有元素的 XML element,按块归类
+ body = document.element.body
+
+ # 建立 index → element 映射
+ index_to_element: dict[int, any] = {}
+ for index, item in enumerate(all_items):
+ index_to_element[index] = item._element
+
+ # 记录哪些索引已被块覆盖
+ covered_indices: set[int] = set()
+ for block in blocks:
+ start_idx = block.get("anchor_start_index")
+ end_idx = block.get("anchor_end_index", start_idx)
+ if start_idx is not None:
+ for idx in range(start_idx, (end_idx or start_idx) + 1):
+ if idx < len(all_items):
+ covered_indices.add(idx)
+
+ # 未被任何块覆盖的元素(gap),保持原顺序
+ gap_elements: list = []
+ for index, item in enumerate(all_items):
+ if index not in covered_indices:
+ gap_elements.append(item._element)
+
+ # 块元素:按 sort_index 排序后收集
+ block_elements: list = []
+ for block in sorted_blocks:
+ start_idx = block.get("anchor_start_index")
+ end_idx = block.get("anchor_end_index", start_idx)
+ if start_idx is not None:
+ for idx in range(start_idx, (end_idx or start_idx) + 1):
+ if idx < len(all_items) and idx in covered_indices:
+ block_elements.append(index_to_element[idx])
+
+ # 移除所有元素
+ for element in list(body):
+ body.remove(element)
+
+ # 按新顺序重新添加:块元素(按 sort_index)→ 间隙元素(保持原顺序)
+ for element in block_elements:
+ body.append(element)
+ for element in gap_elements:
+ body.append(element)
+
+ output = BytesIO()
+ document.save(output)
+ return output.getvalue()
diff --git a/backend/services/generation_runtime.py b/backend/services/generation_runtime.py
index 535f4c5..10b83a3 100644
--- a/backend/services/generation_runtime.py
+++ b/backend/services/generation_runtime.py
@@ -11,6 +11,7 @@ from models.document import Document
from models.generation_log import GenerationLog
from models.paragraph import Paragraph
from models.template import Template
+from models.template_block import TemplateBlock
from services.ai_service import call_ai
from services.file_summary import summarize_minio_files
@@ -195,3 +196,160 @@ async def run_generation(document_id: int, template_id: int):
update_progress(document_id, status="failed", message=str(error), done=done_count, total=total)
finally:
generation_cancel_flags.pop(document_id, None)
+
+
+def build_mock_content_for_block(block: TemplateBlock) -> dict:
+ """为块生成模拟内容。"""
+ if block.output_format == "table":
+ return {
+ "content": [
+ {
+ "type": "table",
+ "title": block.title,
+ "headers": ["字段", "内容"],
+ "rows": [
+ ["块标题", block.title],
+ ["块类型", block.block_type],
+ ],
+ }
+ ]
+ }
+ return {
+ "content": [
+ {"type": "text", "text": f"这是“{block.title or block.block_type}”的示例生成内容。"}
+ ]
+ }
+
+
+async def run_generation_for_blocks(document_id: int, template_id: int):
+ """基于块结构的 AI 生成任务。"""
+ async with async_session() as db:
+ document = await db.get(Document, document_id)
+ template = await db.get(Template, template_id)
+ if document is None or template is None:
+ update_progress(document_id, status="failed", message="生成任务初始化失败")
+ return
+
+ result = await db.execute(
+ select(TemplateBlock)
+ .where(TemplateBlock.template_id == template_id)
+ .order_by(TemplateBlock.sort_index.asc(), TemplateBlock.id.asc())
+ )
+ blocks = result.scalars().all()
+ total = len(blocks)
+ ai_blocks = [b for b in blocks if b.edit_mode == "ai"]
+ total_ai = len(ai_blocks)
+
+ update_progress(document_id, status="generating", total=total_ai, done=0, percent=0, message="开始块级生成...")
+
+ done_count = 0
+ failed_count = 0
+
+ # 收集固定块内容作为上下文
+ fixed_context_parts: list[str] = []
+ for b in blocks:
+ if b.edit_mode == "manual" and b.title:
+ fixed_context_parts.append(f"[{b.block_type}] {b.title}")
+
+ try:
+ for block in blocks:
+ if is_cancel_requested(document_id):
+ document.status = "cancelled"
+ document.error = "用户已取消生成"
+ await db.commit()
+ update_progress(document_id, status="cancelled", message="已取消生成", done=done_count)
+ return
+
+ if block.edit_mode == "manual":
+ # 固定块:使用现有内容
+ existing = json.loads(block.content_json or "{}")
+ content = {"content": [{"type": "text", "text": existing.get("text", block.title or "")}]}
+ status = "success"
+ duration = 0
+ error_message = ""
+ model_id = block.model_id
+ else:
+ # AI 块:调用模型生成
+ start = time.perf_counter()
+ model = None
+ if block.model_id:
+ model = await db.get(AiModel, block.model_id)
+ if model is None or model.status != "enabled":
+ # 尝试使用默认启用的模型
+ result_enabled = await db.execute(
+ select(AiModel).where(AiModel.status == "enabled").order_by(AiModel.id.asc()).limit(1)
+ )
+ model = result_enabled.scalars().first()
+
+ model_id = model.id if model is not None else block.model_id
+ try:
+ if model is None:
+ content = build_mock_content_for_block(block)
+ else:
+ # 构造临时 paragraph 对象用于兼容 call_ai
+ temp_para = Paragraph(
+ template_id=template_id,
+ sort_index=block.sort_index,
+ title=block.title,
+ content="\n".join(fixed_context_parts),
+ prompt_text=block.prompt_text or "",
+ output_format=block.output_format,
+ need_prompt=block.need_prompt,
+ edit_mode=block.edit_mode,
+ )
+ setattr(temp_para, "enable_reasoning", bool(model.enable_reasoning))
+ result_data = await call_ai(temp_para, model, None)
+ content = result_data.content
+ status = "success"
+ error_message = ""
+ except Exception as error:
+ content = build_mock_content_for_block(block)
+ status = "failed"
+ error_message = str(error)
+ failed_count += 1
+ duration = round(time.perf_counter() - start, 4)
+ done_count += 1
+
+ log = GenerationLog(
+ document_id=document.id,
+ block_id=block.id,
+ model_id=model_id,
+ status=status,
+ content=json.dumps(content, ensure_ascii=False),
+ duration=duration,
+ error_msg=error_message,
+ )
+ db.add(log)
+ document.para_count_done = done_count
+ percent = int(done_count / max(total_ai, 1) * 100)
+ update_progress(
+ document_id,
+ status="generating",
+ percent=percent,
+ done=done_count,
+ total=total_ai,
+ current_paragraph=block.title,
+ message=f"正在生成:{block.title or block.block_type}",
+ )
+ await db.commit()
+
+ document.status = "completed" if failed_count == 0 else "failed"
+ document.error = "" if failed_count == 0 else f"{failed_count} 个 AI 块生成失败。"
+ document.file_path = f"mock://document/{document.id}"
+ document.updated_at = datetime.now()
+ await db.commit()
+ update_progress(
+ document_id,
+ status=document.status,
+ percent=100,
+ done=done_count,
+ total=total_ai,
+ message="块级生成完成" if failed_count == 0 else document.error,
+ )
+ except Exception as error:
+ document.status = "failed"
+ document.error = str(error)
+ await db.commit()
+ update_progress(document_id, status="failed", message=str(error), done=done_count, total=total_ai)
+ finally:
+ generation_cancel_flags.pop(document_id, None)
diff --git a/backend/services/word_to_html.py b/backend/services/word_to_html.py
new file mode 100644
index 0000000..c5ccdba
--- /dev/null
+++ b/backend/services/word_to_html.py
@@ -0,0 +1,673 @@
+"""将 docx 文件转为保留样式的 HTML,每个元素标记 data-block-index 用于框选定位。"""
+import base64
+import html as html_module
+import io
+import json
+import re
+from dataclasses import dataclass
+from typing import Union
+
+from docx import Document
+from docx.document import Document as DocumentObject
+from docx.image.exceptions import UnrecognizedImageError
+from docx.opc.constants import RELATIONSHIP_TYPE as RT
+from docx.oxml.ns import qn
+from docx.oxml.table import CT_Tbl
+from docx.oxml.text.paragraph import CT_P
+from docx.table import Table
+from docx.text.paragraph import Paragraph
+
+
+@dataclass
+class RenderedBlock:
+ """单个渲染块 —— 对应模板中的一个 Block。"""
+ block_index: int
+ block_type: str # heading / text / table / image
+ html: str # 该块的 HTML 片段
+ text_content: str # 纯文本(用于块列表展示)
+ style_json: str # 样式快照 JSON
+ table_json: str # 表格结构 JSON(仅 table 类型有值)
+
+
+# ---------------------------------------------------------------------------
+# 工具函数
+# ---------------------------------------------------------------------------
+
+def _escape(text: str) -> str:
+ return html_module.escape(text)
+
+
+def _safe_pt(value) -> float | None:
+ if value is None:
+ return None
+ try:
+ return round(float(value.pt), 2)
+ except AttributeError:
+ return None
+
+
+def _safe_indent(value) -> float | None:
+ if value is None:
+ return None
+ try:
+ return round(float(value.pt), 2)
+ except AttributeError:
+ return None
+
+
+def _heading_level(style_name: str) -> int | None:
+ if not style_name:
+ return None
+ normalized = style_name.lower().replace(" ", "")
+ if normalized.startswith("heading"):
+ level = normalized.replace("heading", "")
+ if level.isdigit():
+ return int(level)
+ return None
+
+
+def _get_list_info(paragraph: Paragraph) -> tuple[bool, int, str | None]:
+ """检测段落是否为列表项,返回 (is_list, level, list_type)。
+
+ list_type: 'ordered' / 'unordered' / None
+ level: 缩进层级(0-based)
+ """
+ pPr = paragraph._element.pPr
+ if pPr is None:
+ return False, 0, None
+
+ numPr = pPr.find(qn("w:numPr"))
+ if numPr is None:
+ return False, 0, None
+
+ ilvl_elem = numPr.find(qn("w:ilvl"))
+ numId_elem = numPr.find(qn("w:numId"))
+ if numId_elem is None:
+ return False, 0, None
+
+ level = int(ilvl_elem.get(qn("w:val"))) if ilvl_elem is not None else 0
+
+ # 尝试从文档 numbering 部分获取列表类型
+ list_type = "unordered"
+ try:
+ num_id_val = numId_elem.get(qn("w:numId"))
+ if num_id_val is not None:
+ document = paragraph._parent
+ # 查找 numbering 定义
+ numbering_part = None
+ if hasattr(document, 'part'):
+ numbering_part = document.part.numbering_part
+ elif hasattr(paragraph._parent, 'part'):
+ numbering_part = paragraph._parent.part.numbering_part
+
+ if numbering_part is not None:
+ numbering_xml = numbering_part._element
+ # 查找 numId 对应的 abstractNumId
+ num_elem = numbering_xml.find(f'.//w:num[@w:numId="{num_id_val}"]',
+ {'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'})
+ if num_elem is not None:
+ abstract_num_ref = num_elem.find(qn("w:abstractNumId"))
+ if abstract_num_ref is not None:
+ abstract_num_id = abstract_num_ref.get(qn("w:val"))
+ # 查找 abstractNum 的 lvl 定义
+ for lvl_elem in numbering_xml.findall(
+ f'.//w:abstractNum[@w:abstractNumId="{abstract_num_id}"]/w:lvl',
+ {'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'}
+ ):
+ lvl_ilvl = lvl_elem.get(qn("w:ilvl"))
+ if lvl_ilvl is not None and int(lvl_ilvl) == level:
+ num_fmt = lvl_elem.find(qn("w:numFmt"))
+ if num_fmt is not None:
+ fmt_val = num_fmt.get(qn("w:val"))
+ if fmt_val == "bullet":
+ list_type = "unordered"
+ else:
+ list_type = "ordered"
+ break
+ except Exception:
+ pass
+
+ return True, level, list_type
+
+
+def _alignment_css(paragraph: Paragraph) -> str:
+ from docx.enum.text import WD_ALIGN_PARAGRAPH
+ mapping = {
+ WD_ALIGN_PARAGRAPH.CENTER: "center",
+ WD_ALIGN_PARAGRAPH.RIGHT: "right",
+ WD_ALIGN_PARAGRAPH.JUSTIFY: "justify",
+ }
+ return mapping.get(paragraph.alignment, "left")
+
+
+def _get_run_style(run) -> dict:
+ """提取 run 的字体样式。"""
+ font = run.font
+ color = "#000000"
+ if font.color is not None and font.color.rgb is not None:
+ color = f"#{font.color.rgb}"
+ size = _safe_pt(font.size)
+ return {
+ "name": font.name,
+ "size": size,
+ "bold": bool(font.bold) if font.bold is not None else False,
+ "italic": bool(font.italic) if font.italic is not None else False,
+ "underline": bool(font.underline) if font.underline is not None else False,
+ "color": color,
+ }
+
+
+def _run_style_inline(run) -> str:
+ """将 run 的字体样式转为 CSS inline。"""
+ s = _get_run_style(run)
+ css = ""
+ if s["name"]:
+ css += f"font-family:'{s['name']}',sans-serif;"
+ if s["size"]:
+ css += f"font-size:{s['size']}pt;"
+ if s["bold"]:
+ css += "font-weight:bold;"
+ if s["italic"]:
+ css += "font-style:italic;"
+ if s["underline"]:
+ css += "text-decoration:underline;"
+ if s["color"] and s["color"] != "#000000":
+ css += f"color:{s['color']};"
+ return css
+
+
+def _para_style_inline(paragraph: Paragraph) -> str:
+ """段落级别样式(对齐、间距、缩进)。"""
+ fmt = paragraph.paragraph_format
+ css = ""
+ css += f"text-align:{_alignment_css(paragraph)};"
+ before = _safe_pt(fmt.space_before)
+ after = _safe_pt(fmt.space_after)
+ if before:
+ css += f"margin-top:{before}pt;"
+ if after:
+ css += f"margin-bottom:{after}pt;"
+ line_spacing = fmt.line_spacing
+ if line_spacing is not None:
+ if isinstance(line_spacing, float):
+ css += f"line-height:{line_spacing};"
+ else:
+ try:
+ css += f"line-height:{round(float(line_spacing.pt), 2)}pt;"
+ except AttributeError:
+ pass
+ indent = _safe_indent(fmt.first_line_indent)
+ if indent:
+ css += f"text-indent:{indent}pt;"
+ return css
+
+
+def _capture_style_snapshot(paragraph: Paragraph, level: int = 0) -> dict:
+ """段落样式快照(用于后续导出时还原)。"""
+ fmt = paragraph.paragraph_format
+ first_run_style = None
+ for run in paragraph.runs:
+ if run.text.strip():
+ first_run_style = _get_run_style(run)
+ break
+ return {
+ "font": first_run_style or {},
+ "paragraph": {
+ "alignment": _alignment_css(paragraph),
+ "spaceBefore": _safe_pt(fmt.space_before),
+ "spaceAfter": _safe_pt(fmt.space_after),
+ "lineSpacing": fmt.line_spacing,
+ "firstLineIndent": _safe_indent(fmt.first_line_indent),
+ },
+ "headingLevel": level,
+ }
+
+
+# ---------------------------------------------------------------------------
+# 图片提取
+# ---------------------------------------------------------------------------
+
+def _extract_images_from_docx(file_path: str) -> dict[str, str]:
+ """提取 docx 中的所有图片,返回 {rId: base64_data_uri} 映射。"""
+ document = Document(file_path)
+ images: dict[str, str] = {}
+ for rel in document.part.rels.values():
+ if "image" in rel.reltype:
+ try:
+ image_bytes = rel.target_part.blob
+ ext = rel.target_part.partname.split(".")[-1].lower()
+ mime_map = {"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg",
+ "gif": "image/gif", "bmp": "image/bmp", "webp": "image/webp", "svg": "image/svg+xml"}
+ mime = mime_map.get(ext, "image/png")
+ data_uri = f"data:{mime};base64,{base64.b64encode(image_bytes).decode('ascii')}"
+ images[rel.rId] = data_uri
+ except Exception:
+ continue
+ return images
+
+
+def _paragraph_contains_image(paragraph: Paragraph) -> bool:
+ """检查段落是否包含内嵌图片。"""
+ drawings = paragraph._element.findall(".//" + qn("w:drawing"))
+ return len(drawings) > 0
+
+
+def _render_image_html(paragraph: Paragraph, images: dict[str, str], block_index: int) -> str:
+ """渲染段落中的图片为 HTML img 标签。"""
+ nsmap = {
+ "a": "http://schemas.openxmlformats.org/drawingml/2006/main",
+ "r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
+ "wp": "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing",
+ "pic": "http://schemas.openxmlformats.org/drawingml/2006/picture",
+ }
+ parts: list[str] = []
+ drawings = paragraph._element.findall(".//" + qn("w:drawing"))
+ for drawing in drawings:
+ blip = drawing.find(".//" + qn("a:blip"), nsmap)
+ if blip is None:
+ continue
+ r_embed = blip.get(qn("r:embed"))
+ if r_embed and r_embed in images:
+ src = images[r_embed]
+ # 尝试获取图片尺寸
+ ext = drawing.find(".//" + qn("wp:extent"), nsmap)
+ cx = ext.get("cx") if ext is not None else None
+ cy = ext.get("cy") if ext is not None else None
+ style = "max-width:100%;height:auto;"
+ if cx and cy:
+ w = int(cx) // 9525
+ h = int(cy) // 9525
+ style = f"width:{w}px;height:{h}px;"
+ parts.append(f'')
+ return "\n".join(parts)
+
+
+# ---------------------------------------------------------------------------
+# 表格渲染
+# ---------------------------------------------------------------------------
+
+def _parse_border(border_element) -> dict | None:
+ """解析单个 CT_Border 元素。"""
+ if border_element is None:
+ return None
+ color = border_element.get(qn("w:color")) or "000000"
+ sz = border_element.get(qn("w:sz")) or "4"
+ val = border_element.get(qn("w:val")) or "single"
+ if val == "nil" or val == "none":
+ return None
+ try:
+ width_px = max(1, int(sz) // 8)
+ except ValueError:
+ width_px = 1
+ return {"color": f"#{color}", "width": f"{width_px}px", "style": val}
+
+
+def _table_border_css(table: Table) -> str:
+ """提取表格整体的边框样式。"""
+ tbl_pr = table._tbl.tblPr
+ if tbl_pr is None:
+ return "border-collapse:collapse;"
+ borders = tbl_pr.find(qn("w:tblBorders"))
+ if borders is None:
+ return "border-collapse:collapse;"
+ top = _parse_border(borders.find(qn("w:top")))
+ left = _parse_border(borders.find(qn("w:left")))
+ bottom = _parse_border(borders.find(qn("w:bottom")))
+ right = _parse_border(borders.find(qn("w:right")))
+ inside_h = _parse_border(borders.find(qn("w:insideH")))
+ inside_v = _parse_border(borders.find(qn("w:insideV")))
+
+ rules: list[str] = ["border-collapse:collapse;"]
+ border_color = "#000000"
+ border_width = "1px"
+ if top:
+ border_color = top["color"]
+ border_width = top["width"]
+ elif left:
+ border_color = left["color"]
+ border_width = left["width"]
+
+ rules.append(f"border:{border_width} solid {border_color};")
+ rules.append(f"th,td{{border:{border_width} solid {border_color};}}")
+ return "".join(rules)
+
+
+def _cell_shading(cell) -> str:
+ """提取单元格底纹颜色。"""
+ tc_pr = cell._tc.tcPr
+ if tc_pr is None:
+ return ""
+ shading = tc_pr.find(qn("w:shd"))
+ if shading is None:
+ return ""
+ fill = shading.get(qn("w:fill"))
+ if fill and fill != "auto":
+ return f"background-color:#{fill};"
+ return ""
+
+
+def _cell_width(cell) -> str:
+ """提取单元格宽度。"""
+ tc_pr = cell._tc.tcPr
+ if tc_pr is None:
+ return ""
+ tc_w = tc_pr.find(qn("w:tcW"))
+ if tc_w is None:
+ return ""
+ w = tc_w.get(qn("w:w"))
+ if w:
+ return f"width:{w};"
+ return ""
+
+
+def _cell_v_align(cell) -> str:
+ """提取单元格垂直对齐。"""
+ tc_pr = cell._tc.tcPr
+ if tc_pr is None:
+ return ""
+ v_align = tc_pr.find(qn("w:vAlign"))
+ if v_align is None:
+ return ""
+ val = v_align.get(qn("w:val"))
+ if val:
+ return f"vertical-align:{val};"
+ return ""
+
+
+def _render_cell_html(cell, is_header: bool = False) -> str:
+ """渲染单个单元格为 HTML。"""
+ tag = "th" if is_header else "td"
+ colspan = 1
+ tc_pr = cell._tc.tcPr
+ if tc_pr is not None and tc_pr.gridSpan is not None:
+ colspan = tc_pr.gridSpan.val
+
+ content_parts: list[str] = []
+ for paragraph in cell.paragraphs:
+ para_css = _para_style_inline(paragraph)
+ inner_spans: list[str] = []
+ for run in paragraph.runs:
+ text = _escape(run.text)
+ if not text:
+ continue
+ run_css = _run_style_inline(run)
+ inner_spans.append(f'{text}')
+ inner_html = "".join(inner_spans) if inner_spans else " "
+ content_parts.append(f'
{inner}
' + ) + + blocks.append(RenderedBlock( + block_index=block_index, + block_type=block_type, + html=html_str, + text_content=text, + style_json=json.dumps(_capture_style_snapshot(item, level or 0), ensure_ascii=False), + table_json="{}", + )) + block_index += 1 + + elif isinstance(item, Table): + table_html = _render_table_html(item) + table_structure = _extract_table_structure(item) + + # 获取表格中第一个非空文本作为描述 + first_texts: list[str] = [] + for row in item.rows: + for cell in row.cells: + txt = cell.text.strip() + if txt: + first_texts.append(txt) + break + if first_texts: + break + text_content = " | ".join(first_texts[:3]) if first_texts else f"表格 {table_structure['rows']}行{table_structure['cols']}列" + + html_str = ( + f'