模板在线编辑与导出链路重构
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user