模板在线编辑与导出链路重构

This commit is contained in:
zwt13703
2026-07-05 23:35:00 +08:00
parent 1369d87afb
commit c84ec6aa71
15 changed files with 1434 additions and 166 deletions
+4
View File
@@ -22,6 +22,7 @@ async def get_db():
async def init_db():
from models.template import Template
from models.paragraph import Paragraph
from models.template_block import TemplateBlock
from models.ai_model import AiModel
from models.document import Document
from models.generation_log import GenerationLog
@@ -53,3 +54,6 @@ async def init_db():
await conn.execute(text("UPDATE paragraphs SET anchor_title = title WHERE anchor_title = '' OR anchor_title IS NULL"))
if "write_mode" not in paragraph_columns:
await conn.execute(text("ALTER TABLE paragraphs ADD COLUMN write_mode VARCHAR(30) DEFAULT 'replace_section'"))
block_tables = await conn.run_sync(lambda sync_conn: inspect(sync_conn).get_table_names())
if "template_blocks" not in block_tables:
await conn.run_sync(lambda sync_conn: TemplateBlock.__table__.create(sync_conn))
+1
View File
@@ -1,5 +1,6 @@
from models.template import Template
from models.paragraph import Paragraph
from models.template_block import TemplateBlock
from models.ai_model import AiModel
from models.document import Document
from models.generation_log import GenerationLog
+30
View File
@@ -0,0 +1,30 @@
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text, func
from database import Base
class TemplateBlock(Base):
__tablename__ = "template_blocks"
id = Column(Integer, primary_key=True, autoincrement=True)
template_id = Column(Integer, ForeignKey("templates.id"), nullable=False)
source_paragraph_id = Column(Integer, ForeignKey("paragraphs.id"), nullable=True)
parent_block_id = Column(Integer, ForeignKey("template_blocks.id"), nullable=True)
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="原始锚点引用")
title = Column(String(500), default="", comment="块标题")
content_json = Column(Text, default="{}", comment="块内容 JSON")
style_json = Column(Text, default="{}", comment="块样式 JSON")
edit_mode = Column(String(20), default="manual", comment="manual/ai")
placeholder_key = Column(String(120), default="", comment="AI 占位键")
variable_key = Column(String(120), default="", comment="变量键")
default_value = Column(Text, default="", comment="默认值")
model_id = Column(Integer, ForeignKey("ai_models.id"), nullable=True, comment="指定模型")
need_prompt = Column(Boolean, default=True, comment="是否需要提示词")
prompt_text = Column(Text, default="", comment="预设提示词")
need_file = Column(Boolean, default=False, comment="是否需要上传参考文件")
file_note = Column(Text, default="", comment="参考文件说明")
output_format = Column(String(20), default="text", comment="text/table/mixed/chart")
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
+81 -13
View File
@@ -15,6 +15,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.document_export import export_document_bytes
from services.minio_client import (
download_object_bytes,
@@ -26,6 +27,46 @@ from services.minio_client import (
router = APIRouter()
def _block_text_content(block: TemplateBlock) -> str:
try:
payload = json.loads(block.content_json or "{}")
except Exception:
payload = {}
return payload.get("text") or block.default_value or ""
def _block_table_content(block: TemplateBlock) -> dict:
try:
payload = json.loads(block.content_json or "{}")
except Exception:
payload = {}
return payload.get("table") or {}
def _build_block_export_content(block: TemplateBlock) -> dict:
if block.block_type == "table":
table_data = _block_table_content(block)
matrix = table_data.get("data") or []
headers = matrix[0] if matrix else []
rows = matrix[1:] if len(matrix) > 1 else []
return {"content": [{"type": "table", "headers": headers, "rows": rows}]}
return {"content": [{"type": "text", "text": _block_text_content(block)}]}
def _resolve_block_write_modes(blocks: list[TemplateBlock]) -> list[str]:
modes: list[str] = []
anchor_counter: dict[str, int] = {}
for block in blocks:
if block.block_type == "heading":
modes.append("replace_heading_only")
continue
anchor = (block.anchor_ref or block.title or "").strip()
seen = anchor_counter.get(anchor, 0)
modes.append("replace_section" if seen == 0 else "append_after_heading")
anchor_counter[anchor] = seen + 1
return modes
@router.get("/{document_id}/docx")
async def export_docx(document_id: int, db: AsyncSession = Depends(get_db)):
document = await db.get(Document, document_id)
@@ -39,22 +80,49 @@ async def export_docx(document_id: int, db: AsyncSession = Depends(get_db)):
template_bucket, template_object = split_bucket_path(template.file_path)
template_bytes = await asyncio.to_thread(download_object_bytes, template_bucket, template_object)
result = await db.execute(
select(GenerationLog, Paragraph)
.join(Paragraph, Paragraph.id == GenerationLog.paragraph_id)
.where(GenerationLog.document_id == document_id)
.order_by(Paragraph.sort_index.asc(), Paragraph.id.asc())
block_result = await db.execute(
select(TemplateBlock)
.where(TemplateBlock.template_id == template.id)
.order_by(TemplateBlock.sort_index.asc(), TemplateBlock.id.asc())
)
blocks = block_result.scalars().all()
log_result = await db.execute(
select(GenerationLog).where(GenerationLog.document_id == document_id)
)
generation_logs = log_result.scalars().all()
log_map = {item.paragraph_id: json.loads(item.content) if item.content else {"content": []} for item in generation_logs}
logs = []
for log, paragraph in result.all():
logs.append(
{
"anchor_title": paragraph.anchor_title or paragraph.title,
"title": paragraph.title,
"write_mode": paragraph.write_mode,
"content": json.loads(log.content) if log.content else {"content": []},
}
if blocks:
write_modes = _resolve_block_write_modes(blocks)
for block, write_mode in zip(blocks, write_modes):
generated_content = log_map.get(block.source_paragraph_id) if block.source_paragraph_id else None
content = generated_content if (block.edit_mode == "ai" or block.block_type == "ai_slot") and generated_content else _build_block_export_content(block)
logs.append(
{
"anchor_title": block.anchor_ref or block.title,
"title": block.title,
"write_mode": write_mode,
"content": content,
}
)
else:
result = await db.execute(
select(GenerationLog, Paragraph)
.join(Paragraph, Paragraph.id == GenerationLog.paragraph_id)
.where(GenerationLog.document_id == document_id)
.order_by(Paragraph.sort_index.asc(), Paragraph.id.asc())
)
for log, paragraph in result.all():
logs.append(
{
"anchor_title": paragraph.anchor_title or paragraph.title,
"title": paragraph.title,
"write_mode": paragraph.write_mode,
"content": json.loads(log.content) if log.content else {"content": []},
}
)
exported_bytes = await asyncio.to_thread(export_document_bytes, template_bytes, logs)
object_name = f"{datetime.now().strftime('%Y%m%d')}/{uuid.uuid4().hex}.docx"
+310 -6
View File
@@ -1,4 +1,5 @@
import asyncio
import json
import os
import tempfile
import uuid
@@ -15,8 +16,10 @@ 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.document_export import export_document_bytes
from services.minio_client import download_object_bytes, minio_client, split_bucket_path, upload_bytes
from services.template_parser import parse_template
router = APIRouter()
@@ -64,6 +67,270 @@ def _serialize_template(template: Template) -> dict:
}
def _build_block_from_paragraph(paragraph: Paragraph) -> dict:
block_type = "heading" if paragraph.write_mode == "replace_heading_only" else ("ai_slot" if paragraph.edit_mode == "ai" else ("table" if paragraph.is_table else "text"))
content_json = json.dumps({
"text": paragraph.content or "",
"table": json.loads(paragraph.table_json or "{}") if paragraph.is_table else None,
}, ensure_ascii=False)
return {
"source_paragraph_id": paragraph.id,
"parent_block_id": None,
"sort_index": paragraph.sort_index,
"block_type": block_type,
"anchor_ref": paragraph.anchor_title or paragraph.title,
"title": paragraph.title,
"content_json": content_json,
"style_json": paragraph.style_json or "{}",
"edit_mode": paragraph.edit_mode,
"placeholder_key": "",
"variable_key": "",
"default_value": paragraph.content or "",
"model_id": paragraph.model_id,
"need_prompt": paragraph.need_prompt,
"prompt_text": paragraph.prompt_text,
"need_file": paragraph.need_file,
"file_note": paragraph.file_note,
"output_format": paragraph.output_format,
}
def _build_block_from_parsed_item(item, source_paragraph_id: int | None) -> dict:
content_json = json.dumps({
"text": item.content or "",
"table": json.loads(item.table_json or "{}") if item.is_table else None,
}, ensure_ascii=False)
return {
"source_paragraph_id": source_paragraph_id,
"parent_block_id": None,
"sort_index": item.sort_index,
"block_type": item.block_type,
"anchor_ref": item.anchor_title or item.title,
"title": item.title,
"content_json": content_json,
"style_json": item.style_json or "{}",
"edit_mode": item.edit_mode,
"placeholder_key": item.placeholder_key,
"variable_key": item.variable_key,
"default_value": item.default_value,
"model_id": None,
"need_prompt": True,
"prompt_text": "",
"need_file": False,
"file_note": "",
"output_format": item.output_format,
}
def _serialize_block(block: TemplateBlock) -> dict:
try:
content_json = json.loads(block.content_json or "{}")
except Exception:
content_json = {}
return {
"id": block.id,
"template_id": block.template_id,
"source_paragraph_id": block.source_paragraph_id,
"parent_block_id": block.parent_block_id,
"sort_index": block.sort_index,
"block_type": block.block_type,
"anchor_ref": block.anchor_ref,
"title": block.title,
"content_json": 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,
}
async def _load_blocks(db: AsyncSession, template_id: int) -> list[TemplateBlock]:
result = await db.execute(
select(TemplateBlock)
.where(TemplateBlock.template_id == template_id)
.order_by(TemplateBlock.sort_index.asc(), TemplateBlock.id.asc())
)
return result.scalars().all()
async def _sync_blocks_from_paragraphs(db: AsyncSession, template_id: int, paragraphs: list[Paragraph]):
existing_blocks = await _load_blocks(db, template_id)
for block in existing_blocks:
await db.delete(block)
await db.flush()
block_rows: list[TemplateBlock] = []
for paragraph in paragraphs:
block = TemplateBlock(template_id=template_id, **_build_block_from_paragraph(paragraph))
db.add(block)
block_rows.append(block)
await db.flush()
return block_rows
async def _save_blocks(
db: AsyncSession,
template_id: int,
blocks_payload,
):
existing_blocks = await _load_blocks(db, template_id)
block_map = {item.id: item for item in existing_blocks}
incoming_ids = {config.id for config in blocks_payload if config.id}
for block in existing_blocks:
if block.id not in incoming_ids:
await db.delete(block)
for index, config in enumerate(blocks_payload, start=1):
block = block_map.get(config.id) if config.id else None
if block is None:
block = TemplateBlock(template_id=template_id)
db.add(block)
block.source_paragraph_id = config.source_paragraph_id
block.parent_block_id = config.parent_block_id
block.sort_index = index
block.block_type = config.block_type
block.anchor_ref = config.anchor_ref or config.title
block.title = config.title
block.content_json = json.dumps(config.content_json or {}, ensure_ascii=False)
block.style_json = config.style_json or "{}"
block.edit_mode = config.edit_mode
block.placeholder_key = config.placeholder_key
block.variable_key = config.variable_key
block.default_value = config.default_value
block.model_id = config.model_id
block.need_prompt = config.need_prompt
block.prompt_text = config.prompt_text
block.need_file = config.need_file
block.file_note = config.file_note
block.output_format = config.output_format
await db.flush()
def _block_text_content(block: TemplateBlock) -> str:
try:
payload = json.loads(block.content_json or "{}")
except Exception:
payload = {}
return payload.get("text") or block.default_value or ""
def _block_table_content(block: TemplateBlock) -> dict:
try:
payload = json.loads(block.content_json or "{}")
except Exception:
payload = {}
return payload.get("table") or {}
async def _sync_paragraphs_from_blocks(db: AsyncSession, template_id: int) -> list[Paragraph]:
paragraph_result = await db.execute(
select(Paragraph)
.where(Paragraph.template_id == template_id)
.order_by(Paragraph.sort_index.asc(), Paragraph.id.asc())
)
existing_paragraphs = paragraph_result.scalars().all()
paragraph_map = {item.id: item for item in existing_paragraphs}
blocks = await _load_blocks(db, template_id)
write_modes = _resolve_block_write_modes(blocks)
kept_paragraph_ids: set[int] = set()
synced_rows: list[Paragraph] = []
for index, (block, write_mode) in enumerate(zip(blocks, write_modes), start=1):
paragraph = paragraph_map.get(block.source_paragraph_id) if block.source_paragraph_id else None
if paragraph is None:
paragraph = Paragraph(template_id=template_id)
db.add(paragraph)
await db.flush()
paragraph.sort_index = index
paragraph.anchor_title = block.anchor_ref or block.title
paragraph.title = block.title
paragraph.content = _block_text_content(block)
paragraph.style_json = block.style_json or "{}"
paragraph.is_table = block.block_type == "table"
paragraph.table_json = json.dumps(_block_table_content(block), ensure_ascii=False) if paragraph.is_table else "{}"
paragraph.edit_mode = "ai" if block.block_type == "ai_slot" or block.edit_mode == "ai" else "manual"
paragraph.write_mode = write_mode
paragraph.model_id = block.model_id
paragraph.need_prompt = block.need_prompt
paragraph.prompt_text = block.prompt_text
paragraph.need_file = block.need_file
paragraph.file_note = block.file_note
paragraph.output_format = block.output_format
block.source_paragraph_id = paragraph.id
kept_paragraph_ids.add(paragraph.id)
synced_rows.append(paragraph)
for paragraph in existing_paragraphs:
if paragraph.id in kept_paragraph_ids:
continue
await db.execute(delete(GenerationLog).where(GenerationLog.paragraph_id == paragraph.id))
await db.delete(paragraph)
await db.flush()
return synced_rows
def _build_export_content_from_block(block: TemplateBlock) -> dict:
if block.block_type == "table":
table_data = _block_table_content(block)
matrix = table_data.get("data") or []
headers = matrix[0] if matrix else []
rows = matrix[1:] if len(matrix) > 1 else []
return {"content": [{"type": "table", "headers": headers, "rows": rows}]}
return {"content": [{"type": "text", "text": _block_text_content(block)}]}
def _resolve_block_write_modes(blocks: list[TemplateBlock]) -> list[str]:
modes: list[str] = []
anchor_counter: dict[str, int] = {}
for block in blocks:
if block.block_type == "heading":
modes.append("replace_heading_only")
continue
anchor = (block.anchor_ref or block.title or "").strip()
seen = anchor_counter.get(anchor, 0)
modes.append("replace_section" if seen == 0 else "append_after_heading")
anchor_counter[anchor] = seen + 1
return modes
async def _write_template_snapshot_to_docx(db: AsyncSession, template: Template):
blocks = await _load_blocks(db, template.id)
if not blocks:
return
write_modes = _resolve_block_write_modes(blocks)
logs = []
for block, write_mode in zip(blocks, write_modes):
logs.append(
{
"anchor_title": block.anchor_ref or block.title,
"title": block.title,
"write_mode": write_mode,
"content": _build_export_content_from_block(block),
}
)
template_bucket, template_object = split_bucket_path(template.file_path)
template_bytes = await asyncio.to_thread(download_object_bytes, template_bucket, template_object)
exported_bytes = await asyncio.to_thread(export_document_bytes, template_bytes, logs)
await asyncio.to_thread(
upload_bytes,
template_bucket,
template_object,
exported_bytes,
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
)
@router.get("")
async def list_templates(
page: int = Query(1, ge=1),
@@ -100,9 +367,16 @@ async def get_template(template_id: int, db: AsyncSession = Depends(get_db)):
.where(Paragraph.template_id == template_id)
.order_by(Paragraph.sort_index.asc(), Paragraph.id.asc())
)
paragraphs = [_serialize_paragraph(item) for item in result.scalars().all()]
paragraph_rows = result.scalars().all()
paragraphs = [_serialize_paragraph(item) for item in paragraph_rows]
block_rows = await _load_blocks(db, template_id)
if not block_rows and paragraph_rows:
block_rows = await _sync_blocks_from_paragraphs(db, template_id, paragraph_rows)
await db.commit()
blocks = [_serialize_block(item) for item in block_rows]
payload = _serialize_template(template)
payload["paragraphs"] = paragraphs
payload["blocks"] = blocks
return Response(data=payload)
@@ -161,19 +435,35 @@ async def upload_template(file: UploadFile = File(...), db: AsyncSession = Depen
style_json=item.style_json,
is_table=item.is_table,
table_json=item.table_json,
edit_mode="manual",
edit_mode=item.edit_mode,
write_mode=item.write_mode,
need_prompt=item.edit_mode == "ai",
output_format=item.output_format,
)
db.add(paragraph)
paragraph_rows.append(paragraph)
await db.flush()
existing_blocks = await _load_blocks(db, template.id)
for block in existing_blocks:
await db.delete(block)
await db.flush()
block_rows: list[TemplateBlock] = []
for item, paragraph in zip(parsed_items, paragraph_rows):
block = TemplateBlock(template_id=template.id, **_build_block_from_parsed_item(item, paragraph.id))
db.add(block)
block_rows.append(block)
await db.flush()
await db.commit()
await db.refresh(template)
for paragraph in paragraph_rows:
await db.refresh(paragraph)
for block in block_rows:
await db.refresh(block)
payload = _serialize_template(template)
payload["paragraphs"] = [_serialize_paragraph(item) for item in paragraph_rows]
payload["blocks"] = [_serialize_block(item) for item in block_rows]
return Response(data=payload)
@@ -224,10 +514,24 @@ async def save_template_paragraphs(
paragraph.file_note = config.file_note
paragraph.output_format = config.output_format
await db.flush()
refreshed_result = await db.execute(
select(Paragraph)
.where(Paragraph.template_id == template_id)
.order_by(Paragraph.sort_index.asc(), Paragraph.id.asc())
)
refreshed_paragraphs = refreshed_result.scalars().all()
if body.save_mode == "manual" and body.blocks:
await _save_blocks(db, template_id, body.blocks)
refreshed_paragraphs = await _sync_paragraphs_from_blocks(db, template_id)
else:
await _sync_blocks_from_paragraphs(db, template_id, refreshed_paragraphs)
refreshed_paragraphs = await _sync_paragraphs_from_blocks(db, template_id)
template.paragraph_count = len(refreshed_paragraphs)
await _write_template_snapshot_to_docx(db, template)
await db.commit()
template.paragraph_count = len(body.paragraphs)
await db.commit()
return Response(data={"template_id": template_id, "saved": len(body.paragraphs)})
blocks = [_serialize_block(item) for item in await _load_blocks(db, template_id)]
return Response(data={"template_id": template_id, "saved": len(refreshed_paragraphs), "blocks": blocks})
@router.delete("/{template_id}")
+24
View File
@@ -43,8 +43,32 @@ class ParagraphConfig(BaseModel):
file_note: str = ""
output_format: str = "text"
class TemplateBlockConfig(BaseModel):
id: int = 0
source_paragraph_id: Optional[int] = None
parent_block_id: Optional[int] = None
sort_index: int = 0
block_type: str = "text"
anchor_ref: str = ""
title: str = ""
content_json: dict[str, Any] = Field(default_factory=dict)
style_json: str = "{}"
edit_mode: str = "manual"
placeholder_key: str = ""
variable_key: str = ""
default_value: str = ""
model_id: Optional[int] = None
need_prompt: bool = True
prompt_text: str = ""
need_file: bool = False
file_note: str = ""
output_format: str = "text"
class TemplateSave(BaseModel):
save_mode: str = "paragraph"
paragraphs: list[ParagraphConfig] = []
blocks: list[TemplateBlockConfig] = []
# 模型
class AiModelCreate(BaseModel):
+73
View File
@@ -69,6 +69,77 @@ def _remove_unreferenced_headings(document: DocumentObject, referenced_anchors:
_delete_block(block)
def _ordered_unique_anchors(logs: list[dict]) -> list[str]:
ordered: list[str] = []
seen: set[str] = set()
for item in logs:
anchor = (item.get("anchor_title") or item.get("title") or "").strip()
if not anchor or anchor in seen:
continue
seen.add(anchor)
ordered.append(anchor)
return ordered
def _reorder_heading_sections(document: DocumentObject, ordered_anchors: list[str]):
body = document.element.body
elements = list(body.iterchildren())
pre_heading: list = []
sections: list[tuple[str, list]] = []
found_heading = False
index = 0
while index < len(elements):
child = elements[index]
if isinstance(child, CT_P):
paragraph = Paragraph(child, document)
if _is_heading(paragraph):
found_heading = True
anchor = paragraph.text.strip()
section_elements = [child]
index += 1
while index < len(elements):
current = elements[index]
if isinstance(current, CT_P):
current_paragraph = Paragraph(current, document)
if _is_heading(current_paragraph):
break
section_elements.append(current)
index += 1
sections.append((anchor, section_elements))
continue
if not found_heading:
pre_heading.append(child)
index += 1
if not sections:
return
section_map: dict[str, list[list]] = {}
for anchor, section_elements in sections:
section_map.setdefault(anchor, []).append(section_elements)
all_section_elements = [element for _, section_elements in sections for element in section_elements]
for element in all_section_elements:
parent = element.getparent()
if parent is not None:
parent.remove(element)
sect_pr = None
for child in list(body.iterchildren()):
if not isinstance(child, (CT_P, CT_Tbl)):
sect_pr = child
break
for anchor in ordered_anchors:
for section_elements in section_map.pop(anchor, []):
for element in section_elements:
if sect_pr is not None:
sect_pr.addprevious(element)
else:
body.append(element)
def _clear_paragraph(paragraph: Paragraph):
element = paragraph._element
for child in list(element):
@@ -369,6 +440,8 @@ def _replace_section_group(
def export_document_bytes(template_bytes: bytes, logs: list[dict]) -> bytes:
document = Document(BytesIO(template_bytes))
ordered_anchors = _ordered_unique_anchors(logs)
_reorder_heading_sections(document, ordered_anchors)
referenced_anchors: set[str] = set()
for item in logs:
+92 -34
View File
@@ -1,4 +1,5 @@
import json
import re
from collections.abc import Iterator
from dataclasses import dataclass
@@ -22,6 +23,12 @@ class ParsedParagraph:
is_table: bool
table_json: str
write_mode: str
block_type: str = "text"
placeholder_key: str = ""
variable_key: str = ""
default_value: str = ""
edit_mode: str = "manual"
output_format: str = "text"
def _iter_block_items(document: DocumentObject) -> Iterator[Paragraph | Table]:
@@ -159,11 +166,35 @@ def _extract_table_data(table: Table) -> dict:
}
PLACEHOLDER_PATTERN = re.compile(r"^\{\{\s*([a-zA-Z0-9_\-\.]+)\s*\}\}$")
def _build_block_title(text: str, fallback: str) -> str:
normalized = " ".join((text or "").split())
if not normalized:
return fallback
return normalized[:24] + ("..." if len(normalized) > 24 else "")
def _classify_placeholder(text: str) -> tuple[str, str, str]:
matched = PLACEHOLDER_PATTERN.match(text.strip())
if not matched:
return "text", "", ""
key = matched.group(1)
lowered = key.lower()
if any(token in lowered for token in ("summary", "opening", "section", "content", "analysis")):
return "ai_slot", key, ""
return "variable", "", key
def parse_template(file_path: str) -> list[ParsedParagraph]:
document = Document(file_path)
parsed: list[ParsedParagraph] = []
current_item: ParsedParagraph | None = None
current_heading: str | None = None
current_heading_style_json = "{}"
loose_table_count = 0
body_block_count = 0
preface_count = 0
for block in _iter_block_items(document):
if isinstance(block, Paragraph):
@@ -173,52 +204,79 @@ def parse_template(file_path: str) -> list[ParsedParagraph]:
level = _heading_level(block.style.name if block.style is not None else "")
if level is not None:
current_item = ParsedParagraph(
current_heading = text
current_heading_style_json = json.dumps(_capture_paragraph_style(block, level), ensure_ascii=False)
body_block_count = 0
parsed.append(ParsedParagraph(
sort_index=len(parsed) + 1,
anchor_title=text,
title=text,
content="",
style_json=json.dumps(_capture_paragraph_style(block, level), ensure_ascii=False),
style_json=current_heading_style_json,
is_table=False,
table_json="{}",
write_mode="replace_section",
)
parsed.append(current_item)
write_mode="replace_heading_only",
block_type="heading",
edit_mode="manual",
output_format="text",
))
continue
if current_item is None:
current_item = ParsedParagraph(
sort_index=len(parsed) + 1,
anchor_title="未命名段落",
title="未命名段落",
content=text,
style_json=json.dumps(_capture_paragraph_style(block, 0), ensure_ascii=False),
is_table=False,
table_json="{}",
write_mode="replace_section",
)
parsed.append(current_item)
block_type, placeholder_key, variable_key = _classify_placeholder(text)
if current_heading is None:
preface_count += 1
anchor_title = f"文档起始_{preface_count}"
title = _build_block_title(text, anchor_title)
write_mode = "replace_section"
else:
current_item.content = "\n".join(filter(None, [current_item.content, text]))
body_block_count += 1
anchor_title = current_heading
title = _build_block_title(text, f"{current_heading}-正文{body_block_count}")
write_mode = "append_after_heading"
parsed.append(ParsedParagraph(
sort_index=len(parsed) + 1,
anchor_title=anchor_title,
title=title,
content=text,
style_json=json.dumps(_capture_paragraph_style(block, 0), ensure_ascii=False),
is_table=False,
table_json="{}",
write_mode=write_mode,
block_type=block_type,
placeholder_key=placeholder_key,
variable_key=variable_key,
default_value="" if variable_key else text,
edit_mode="ai" if block_type == "ai_slot" else "manual",
output_format="text",
))
else:
table_data = _extract_table_data(block)
table_text = f"[表格] {table_data['rows']}{table_data['cols']}"
if current_item is None:
if current_heading is None:
loose_table_count += 1
current_item = ParsedParagraph(
sort_index=len(parsed) + 1,
anchor_title=f"表格_{loose_table_count}",
title=f"表格_{loose_table_count}",
content=table_text,
style_json="{}",
is_table=True,
table_json=json.dumps(table_data, ensure_ascii=False),
write_mode="replace_section",
)
parsed.append(current_item)
anchor_title = f"表格_{loose_table_count}"
title = anchor_title
write_mode = "replace_section"
else:
current_item.is_table = True
current_item.table_json = json.dumps(table_data, ensure_ascii=False)
current_item.content = "\n".join(filter(None, [current_item.content, table_text]))
body_block_count += 1
anchor_title = current_heading
title = f"{current_heading}-表格{body_block_count}"
write_mode = "append_after_heading"
parsed.append(ParsedParagraph(
sort_index=len(parsed) + 1,
anchor_title=anchor_title,
title=title,
content=table_text,
style_json=current_heading_style_json if current_heading else "{}",
is_table=True,
table_json=json.dumps(table_data, ensure_ascii=False),
write_mode=write_mode,
block_type="table",
default_value=table_text,
edit_mode="manual",
output_format="table",
))
return parsed