修正导出文档内容回写与样式保留
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
from docx import Document
|
from docx import Document
|
||||||
from docx.document import Document as DocumentObject
|
from docx.document import Document as DocumentObject
|
||||||
@@ -38,36 +39,103 @@ def _clear_paragraph(paragraph: Paragraph):
|
|||||||
element.remove(child)
|
element.remove(child)
|
||||||
|
|
||||||
|
|
||||||
def _append_paragraph_after(paragraph: Paragraph, text: str, style_name: str | None = None) -> Paragraph:
|
def _copy_paragraph_format(target: Paragraph, source: Paragraph | None):
|
||||||
|
if source is None:
|
||||||
|
return
|
||||||
|
source_ppr = source._element.pPr
|
||||||
|
if source_ppr is not None:
|
||||||
|
target._element.insert(0, deepcopy(source_ppr))
|
||||||
|
|
||||||
|
|
||||||
|
def _copy_run_format(target_run, source_paragraph: Paragraph | None):
|
||||||
|
if source_paragraph is None:
|
||||||
|
return
|
||||||
|
for source_run in source_paragraph.runs:
|
||||||
|
if source_run._element.rPr is not None:
|
||||||
|
target_run._element.insert(0, deepcopy(source_run._element.rPr))
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
|
def _append_paragraph_after(
|
||||||
|
paragraph: Paragraph,
|
||||||
|
text: str,
|
||||||
|
style_name: str | None = None,
|
||||||
|
template_paragraph: Paragraph | None = None,
|
||||||
|
) -> Paragraph:
|
||||||
new_p = OxmlElement("w:p")
|
new_p = OxmlElement("w:p")
|
||||||
paragraph._element.addnext(new_p)
|
paragraph._element.addnext(new_p)
|
||||||
new_para = Paragraph(new_p, paragraph._parent)
|
new_para = Paragraph(new_p, paragraph._parent)
|
||||||
|
_copy_paragraph_format(new_para, template_paragraph)
|
||||||
if style_name:
|
if style_name:
|
||||||
try:
|
try:
|
||||||
new_para.style = style_name
|
new_para.style = style_name
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
if text:
|
if text:
|
||||||
new_para.add_run(text)
|
run = new_para.add_run(text)
|
||||||
|
_copy_run_format(run, template_paragraph)
|
||||||
return new_para
|
return new_para
|
||||||
|
|
||||||
|
|
||||||
def _append_table_after(paragraph: Paragraph, rows: list[list[str]], headers: list[str] | None = None):
|
def _set_cell_text_with_template(cell, value: str, template_paragraph: Paragraph | None = None):
|
||||||
|
if not cell.paragraphs:
|
||||||
|
cell.text = value
|
||||||
|
return
|
||||||
|
paragraph = cell.paragraphs[0]
|
||||||
|
_clear_paragraph(paragraph)
|
||||||
|
run = paragraph.add_run(value)
|
||||||
|
_copy_run_format(run, template_paragraph)
|
||||||
|
|
||||||
|
|
||||||
|
def _resize_table_rows(table: Table, row_count: int):
|
||||||
|
current_rows = len(table.rows)
|
||||||
|
if current_rows == 0:
|
||||||
|
return
|
||||||
|
if current_rows < row_count:
|
||||||
|
template_row = table.rows[-1]._tr
|
||||||
|
for _ in range(row_count - current_rows):
|
||||||
|
table._tbl.append(deepcopy(template_row))
|
||||||
|
elif current_rows > row_count:
|
||||||
|
for _ in range(current_rows - row_count):
|
||||||
|
table._tbl.remove(table.rows[-1]._tr)
|
||||||
|
|
||||||
|
|
||||||
|
def _fill_table(table: Table, matrix: list[list[str]]):
|
||||||
|
if not matrix:
|
||||||
|
return
|
||||||
|
_resize_table_rows(table, len(matrix))
|
||||||
|
template_cell_paragraph = table.rows[0].cells[0].paragraphs[0] if table.rows and table.rows[0].cells else None
|
||||||
|
for row_index, row_values in enumerate(matrix):
|
||||||
|
row = table.rows[row_index]
|
||||||
|
for col_index, cell in enumerate(row.cells):
|
||||||
|
value = row_values[col_index] if col_index < len(row_values) else ""
|
||||||
|
_set_cell_text_with_template(cell, value, template_cell_paragraph)
|
||||||
|
|
||||||
|
|
||||||
|
def _append_table_after(
|
||||||
|
paragraph: Paragraph,
|
||||||
|
rows: list[list[str]],
|
||||||
|
headers: list[str] | None = None,
|
||||||
|
template_table: Table | None = None,
|
||||||
|
):
|
||||||
|
matrix = [headers, *rows] if headers else rows
|
||||||
|
if template_table is not None:
|
||||||
|
cloned_tbl = deepcopy(template_table._tbl)
|
||||||
|
paragraph._element.addnext(cloned_tbl)
|
||||||
|
cloned_table = Table(cloned_tbl, paragraph._parent)
|
||||||
|
_fill_table(cloned_table, matrix)
|
||||||
|
return cloned_table
|
||||||
|
|
||||||
container = paragraph._parent
|
container = paragraph._parent
|
||||||
table = container.add_table(rows=1, cols=max(len(headers or []), len(rows[0]) if rows else 1))
|
table = container.add_table(rows=max(len(matrix), 1), cols=max(len(headers or []), len(rows[0]) if rows else 1))
|
||||||
if headers:
|
if headers:
|
||||||
header_cells = table.rows[0].cells
|
for row_index, row_values in enumerate(matrix):
|
||||||
for index, value in enumerate(headers):
|
for index, value in enumerate(row_values):
|
||||||
header_cells[index].text = value
|
table.rows[row_index].cells[index].text = value
|
||||||
else:
|
elif rows:
|
||||||
if rows:
|
for row_index, row_values in enumerate(matrix):
|
||||||
first = rows.pop(0)
|
for index, value in enumerate(row_values):
|
||||||
for index, value in enumerate(first):
|
table.rows[row_index].cells[index].text = value
|
||||||
table.rows[0].cells[index].text = value
|
|
||||||
for row in rows:
|
|
||||||
new_row = table.add_row().cells
|
|
||||||
for index, value in enumerate(row):
|
|
||||||
new_row[index].text = value
|
|
||||||
|
|
||||||
tbl = table._tbl
|
tbl = table._tbl
|
||||||
tbl.getparent().remove(tbl)
|
tbl.getparent().remove(tbl)
|
||||||
@@ -87,19 +155,25 @@ def _append_empty_paragraph_after_table(table: Table, style_name: str | None = N
|
|||||||
return new_para
|
return new_para
|
||||||
|
|
||||||
|
|
||||||
def _find_heading_paragraph(document: DocumentObject, heading_text: str) -> Paragraph | None:
|
def _find_heading_paragraph(document: DocumentObject, heading_text: str, after_element=None) -> Paragraph | None:
|
||||||
|
started = after_element is None
|
||||||
for block in _iter_block_items(document):
|
for block in _iter_block_items(document):
|
||||||
if isinstance(block, Paragraph) and _is_heading(block) and block.text.strip() == heading_text.strip():
|
if isinstance(block, Paragraph) and _is_heading(block) and block.text.strip() == heading_text.strip():
|
||||||
|
if started:
|
||||||
return block
|
return block
|
||||||
|
if after_element is not None and block._element == after_element:
|
||||||
|
started = True
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _replace_section_content(document: DocumentObject, heading_title: str, content: dict):
|
def _replace_section_content(document: DocumentObject, heading_title: str, content: dict, after_element=None):
|
||||||
heading = _find_heading_paragraph(document, heading_title)
|
heading = _find_heading_paragraph(document, heading_title, after_element)
|
||||||
if heading is None:
|
if heading is None:
|
||||||
return
|
return after_element
|
||||||
|
|
||||||
first_body_style = None
|
first_body_style = None
|
||||||
|
paragraph_template = None
|
||||||
|
table_template = None
|
||||||
current = heading._element.getnext()
|
current = heading._element.getnext()
|
||||||
blocks_to_remove = []
|
blocks_to_remove = []
|
||||||
while current is not None:
|
while current is not None:
|
||||||
@@ -109,9 +183,14 @@ def _replace_section_content(document: DocumentObject, heading_title: str, conte
|
|||||||
break
|
break
|
||||||
if first_body_style is None and current_paragraph.style is not None:
|
if first_body_style is None and current_paragraph.style is not None:
|
||||||
first_body_style = current_paragraph.style.name
|
first_body_style = current_paragraph.style.name
|
||||||
|
if paragraph_template is None:
|
||||||
|
paragraph_template = current_paragraph
|
||||||
blocks_to_remove.append(current_paragraph)
|
blocks_to_remove.append(current_paragraph)
|
||||||
elif isinstance(current, CT_Tbl):
|
elif isinstance(current, CT_Tbl):
|
||||||
blocks_to_remove.append(Table(current, heading._parent))
|
current_table = Table(current, heading._parent)
|
||||||
|
if table_template is None:
|
||||||
|
table_template = current_table
|
||||||
|
blocks_to_remove.append(current_table)
|
||||||
current = current.getnext()
|
current = current.getnext()
|
||||||
|
|
||||||
for block in blocks_to_remove:
|
for block in blocks_to_remove:
|
||||||
@@ -124,17 +203,31 @@ def _replace_section_content(document: DocumentObject, heading_title: str, conte
|
|||||||
if block_type == "table":
|
if block_type == "table":
|
||||||
rows = [list(row) for row in block.get("rows", [])]
|
rows = [list(row) for row in block.get("rows", [])]
|
||||||
headers = block.get("headers") or []
|
headers = block.get("headers") or []
|
||||||
table = _append_table_after(insert_after, rows, headers)
|
table = _append_table_after(insert_after, rows, headers, table_template)
|
||||||
insert_after = _append_empty_paragraph_after_table(table, first_body_style)
|
insert_after = _append_empty_paragraph_after_table(table, first_body_style)
|
||||||
else:
|
else:
|
||||||
text = block.get("text", "")
|
text = block.get("text", "")
|
||||||
insert_after = _append_paragraph_after(insert_after, text, first_body_style)
|
text_parts = [item for item in text.split("\n") if item] or [text]
|
||||||
|
for text_part in text_parts:
|
||||||
|
insert_after = _append_paragraph_after(
|
||||||
|
insert_after,
|
||||||
|
text_part,
|
||||||
|
first_body_style,
|
||||||
|
paragraph_template,
|
||||||
|
)
|
||||||
|
return heading._element
|
||||||
|
|
||||||
|
|
||||||
def export_document_bytes(template_bytes: bytes, logs: list[dict]) -> bytes:
|
def export_document_bytes(template_bytes: bytes, logs: list[dict]) -> bytes:
|
||||||
document = Document(BytesIO(template_bytes))
|
document = Document(BytesIO(template_bytes))
|
||||||
|
last_heading_element = None
|
||||||
for item in logs:
|
for item in logs:
|
||||||
_replace_section_content(document, item["title"], item["content"])
|
last_heading_element = _replace_section_content(
|
||||||
|
document,
|
||||||
|
item["title"],
|
||||||
|
item["content"],
|
||||||
|
last_heading_element,
|
||||||
|
)
|
||||||
|
|
||||||
output = BytesIO()
|
output = BytesIO()
|
||||||
document.save(output)
|
document.save(output)
|
||||||
|
|||||||
@@ -208,3 +208,14 @@
|
|||||||
2. 调整参考附件序列化方法,将 `created_at` 统一转为 ISO 字符串后再写入任务快照 JSON。
|
2. 调整参考附件序列化方法,将 `created_at` 统一转为 ISO 字符串后再写入任务快照 JSON。
|
||||||
3. 执行后端语法检查,确认修复稳定。
|
3. 执行后端语法检查,确认修复稳定。
|
||||||
- **执行结果**: 当前生成任务提交时不会再因为附件记录中的 `datetime` 字段导致 JSON 序列化失败。
|
- **执行结果**: 当前生成任务提交时不会再因为附件记录中的 `datetime` 字段导致 JSON 序列化失败。
|
||||||
|
|
||||||
|
## 会话 ID: local-20260702182751
|
||||||
|
- [2026-07-02 18:27:51]
|
||||||
|
- **执行原因**: 用户反馈导出的模板与原模板几乎一致,AI 结果没有正确写入,同时模板样式在导出后发生明显偏移。
|
||||||
|
- **执行过程**:
|
||||||
|
1. 检查导出链路,确认原实现采用“删除原内容后新建普通段落/表格”的方式回写,容易导致标题命中不稳和样式丢失。
|
||||||
|
2. 重构导出服务的段落替换逻辑,改为按标题顺序匹配模板中的章节,减少重复标题导致的误替换风险。
|
||||||
|
3. 调整导出时的文本回写方式,优先复用模板原有段落的段落属性与首个 run 的字体样式,再写入 AI 返回文本。
|
||||||
|
4. 调整导出时的表格回写方式,优先克隆模板原有表格结构并填充新数据,尽量保留表格外观和基础样式。
|
||||||
|
5. 执行后端语法检查,确认导出服务改动稳定。
|
||||||
|
- **执行结果**: 当前导出链路已改为“尽量复用模板原始段落/表格样式后写入 AI 内容”,比此前的新建空白内容块方式更接近原模板样式,也更容易把 AI 结果正确写回文档。
|
||||||
|
|||||||
Reference in New Issue
Block a user