doc-forge-remark/backend/services/document_export.py

499 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from io import BytesIO
from copy import deepcopy
from docx import Document
from docx.document import Document as DocumentObject
from docx.oxml import OxmlElement
from docx.oxml.table import CT_Tbl
from docx.oxml.text.paragraph import CT_P
from docx.table import Table, _Cell
from docx.text.paragraph import Paragraph
def _iter_block_items(parent: DocumentObject | _Cell):
parent_elm = parent.element.body if isinstance(parent, DocumentObject) else parent._tc
for child in parent_elm.iterchildren():
if isinstance(child, CT_P):
yield Paragraph(child, parent)
elif isinstance(child, CT_Tbl):
yield Table(child, parent)
def _is_heading(paragraph: Paragraph) -> bool:
style_name = paragraph.style.name if paragraph.style is not None else ""
normalized = style_name.lower().replace(" ", "")
return normalized.startswith("heading")
def _delete_block(block):
element = block._element
parent = element.getparent()
if parent is not None:
parent.remove(element)
def _delete_heading_section(heading: Paragraph):
blocks = [heading]
current = heading._element.getnext()
while current is not None:
if isinstance(current, CT_P):
para = Paragraph(current, heading._parent)
if _is_heading(para):
break
blocks.append(para)
elif isinstance(current, CT_Tbl):
blocks.append(Table(current, heading._parent))
current = current.getnext()
for block in blocks:
_delete_block(block)
def _remove_unreferenced_headings(document: DocumentObject, referenced_anchors: set[str]):
headings_to_remove: list[Paragraph] = []
found_first_heading = False
pre_heading_blocks: list = []
print(f"[EXPORT] referenced_anchors: {referenced_anchors}")
for block in _iter_block_items(document):
if isinstance(block, Paragraph) and _is_heading(block):
found_first_heading = True
text = block.text.strip()
if text not in referenced_anchors:
print(f"[EXPORT] Unreferenced heading found, will remove: '{text}'")
headings_to_remove.append(block)
elif not found_first_heading:
pre_heading_blocks.append(block)
for heading in headings_to_remove:
_delete_heading_section(heading)
if not referenced_anchors:
for block in pre_heading_blocks:
_delete_block(block)
def _clear_paragraph(paragraph: Paragraph):
element = paragraph._element
for child in list(element):
if child.tag.endswith("}r"):
element.remove(child)
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 _extract_first_run_format(source_paragraph: Paragraph | None):
if source_paragraph is None:
return None
for source_run in source_paragraph.runs:
if source_run._element.rPr is not None:
return deepcopy(source_run._element.rPr)
return None
def _set_paragraph_text(
paragraph: Paragraph,
text: str,
style_name: str | None = None,
template_paragraph: Paragraph | None = None,
):
run_format = _extract_first_run_format(template_paragraph)
_clear_paragraph(paragraph)
if style_name:
try:
paragraph.style = style_name
except Exception:
pass
if text:
run = paragraph.add_run(text)
if run_format is not None:
run._element.insert(0, run_format)
def _append_paragraph_after(
paragraph: Paragraph,
text: str,
style_name: str | None = None,
template_paragraph: Paragraph | None = None,
) -> Paragraph:
new_p = OxmlElement("w:p")
paragraph._element.addnext(new_p)
new_para = Paragraph(new_p, paragraph._parent)
_copy_paragraph_format(new_para, template_paragraph)
if style_name:
try:
new_para.style = style_name
except Exception:
pass
if text:
run = new_para.add_run(text)
_copy_run_format(run, template_paragraph)
return new_para
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
table = container.add_table(rows=max(len(matrix), 1), cols=max(len(headers or []), len(rows[0]) if rows else 1))
if headers:
for row_index, row_values in enumerate(matrix):
for index, value in enumerate(row_values):
table.rows[row_index].cells[index].text = value
elif rows:
for row_index, row_values in enumerate(matrix):
for index, value in enumerate(row_values):
table.rows[row_index].cells[index].text = value
tbl = table._tbl
tbl.getparent().remove(tbl)
paragraph._element.addnext(tbl)
return Table(tbl, container)
def _append_empty_paragraph_after_table(table: Table, style_name: str | None = None) -> Paragraph:
new_p = OxmlElement("w:p")
table._tbl.addnext(new_p)
new_para = Paragraph(new_p, table._parent)
if style_name:
try:
new_para.style = style_name
except Exception:
pass
return new_para
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):
if isinstance(block, Paragraph) and _is_heading(block) and block.text.strip() == heading_text.strip():
if started:
return block
if after_element is not None and block._element == after_element:
started = True
return None
def _collect_section_templates(heading: Paragraph):
first_body_style = None
paragraph_template = None
table_template = None
blocks = []
current = heading._element.getnext()
while current is not None:
if isinstance(current, CT_P):
current_paragraph = Paragraph(current, heading._parent)
if _is_heading(current_paragraph):
break
if first_body_style is None and current_paragraph.style is not None:
first_body_style = current_paragraph.style.name
if paragraph_template is None:
paragraph_template = current_paragraph
blocks.append(current_paragraph)
elif isinstance(current, CT_Tbl):
current_table = Table(current, heading._parent)
if table_template is None:
table_template = current_table
blocks.append(current_table)
current = current.getnext()
return first_body_style, paragraph_template, table_template, blocks
def _insert_content_after(
insert_after: Paragraph,
content: dict,
first_body_style: str | None,
paragraph_template: Paragraph | None,
table_template: Table | None,
):
current_anchor: Paragraph = insert_after
content_blocks = content.get("content", [])
for block in content_blocks:
block_type = block.get("type")
if block_type == "table":
rows = [list(row) for row in block.get("rows", [])]
headers = block.get("headers") or []
table = _append_table_after(current_anchor, rows, headers, table_template)
current_anchor = _append_empty_paragraph_after_table(table, first_body_style)
else:
text = block.get("text", "")
text_parts = [item for item in text.split("\n") if item] or [text]
for text_part in text_parts:
current_anchor = _append_paragraph_after(
current_anchor,
text_part,
first_body_style,
paragraph_template,
)
return current_anchor
def _replace_section_content(
document: DocumentObject,
anchor_title: str,
target_title: str,
content: dict,
write_mode: str,
after_element=None,
):
heading = _find_heading_paragraph(document, anchor_title, after_element)
if heading is None:
return after_element
_set_paragraph_text(heading, target_title, heading.style.name if heading.style is not None else None, heading)
first_body_style, paragraph_template, table_template, blocks_to_remove = _collect_section_templates(heading)
if write_mode == "replace_heading_only":
return heading._element
if write_mode == "replace_section":
for block in blocks_to_remove:
_delete_block(block)
_insert_content_after(
heading,
content,
first_body_style,
paragraph_template,
table_template,
)
return heading._element
def _group_logs(logs: list[dict]) -> list[list[dict]]:
groups: list[list[dict]] = []
for item in logs:
anchor_title = item.get("anchor_title") or item.get("title") or ""
if not groups:
groups.append([item])
continue
last_group = groups[-1]
last_anchor = last_group[0].get("anchor_title") or last_group[0].get("title") or ""
if anchor_title == last_anchor:
last_group.append(item)
else:
groups.append([item])
return groups
def _replace_section_group(
document: DocumentObject,
items: list[dict],
after_element=None,
):
first_item = items[0]
anchor_title = first_item.get("anchor_title") or first_item.get("title") or ""
target_title = first_item.get("title") or anchor_title
heading = _find_heading_paragraph(document, anchor_title, after_element)
if heading is None:
return after_element
_set_paragraph_text(heading, target_title, heading.style.name if heading.style is not None else None, heading)
first_body_style, paragraph_template, table_template, blocks_to_remove = _collect_section_templates(heading)
if len(items) == 1 and first_item.get("write_mode") == "replace_heading_only":
return heading._element
preserve_existing = len(items) == 1 and first_item.get("write_mode") == "append_after_heading"
if not preserve_existing:
for block in blocks_to_remove:
_delete_block(block)
current_anchor = heading
for item in items:
current_anchor = _insert_content_after(
current_anchor,
item.get("content") or {"content": []},
first_body_style,
paragraph_template,
table_template,
)
return heading._element
def export_document_bytes(template_bytes: bytes, logs: list[dict]) -> bytes:
document = Document(BytesIO(template_bytes))
referenced_anchors: set[str] = set()
for item in logs:
for key in ("anchor_title", "title"):
val = (item.get(key) or "").strip()
if val:
referenced_anchors.add(val)
print(f"[EXPORT] logs count={len(logs)}, anchor_titles={[(l.get('anchor_title'), l.get('title')) for l in logs]}")
last_heading_element = None
for group in _group_logs(logs):
last_heading_element = _replace_section_group(document, group, last_heading_element)
_remove_unreferenced_headings(document, referenced_anchors)
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()