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

283 lines
9.4 KiB
Python

import json
import re
from collections.abc import Iterator
from dataclasses import dataclass
from docx import Document
from docx.document import Document as DocumentObject
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
from docx.enum.text import WD_ALIGN_PARAGRAPH
@dataclass
class ParsedParagraph:
sort_index: int
anchor_title: str
title: str
content: str
style_json: str
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]:
body = document.element.body
for child in body.iterchildren():
if isinstance(child, CT_P):
yield Paragraph(child, document)
elif isinstance(child, CT_Tbl):
yield Table(child, document)
def _safe_pt(value: object) -> float | None:
if value is None:
return None
try:
return round(float(value.pt), 2)
except AttributeError:
return None
def _safe_indent(value: object) -> float | None:
if value is None:
return None
try:
return round(float(value.pt), 2)
except AttributeError:
return None
def _alignment_name(value: WD_ALIGN_PARAGRAPH | None) -> str:
if value is None:
return "LEFT"
return getattr(value, "name", "LEFT")
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_run_font_info(paragraph: Paragraph) -> dict:
for run in paragraph.runs:
if not run.text.strip():
continue
r_fonts = getattr(run._element.rPr, "rFonts", None) if run._element.rPr is not None else None
east_asia = r_fonts.get(qn("w:eastAsia")) if r_fonts is not None else None
color = None
if run.font.color is not None and run.font.color.rgb is not None:
color = str(run.font.color.rgb)
return {
"name": run.font.name,
"eastAsia": east_asia,
"size": _safe_pt(run.font.size),
"bold": bool(run.bold) if run.bold is not None else False,
"italic": bool(run.italic) if run.italic is not None else False,
"color": color or "000000",
}
return {
"name": None,
"eastAsia": None,
"size": None,
"bold": False,
"italic": False,
"color": "000000",
}
def _capture_paragraph_style(paragraph: Paragraph, level: int) -> dict:
fmt = paragraph.paragraph_format
return {
"font": _get_run_font_info(paragraph),
"paragraph": {
"alignment": _alignment_name(paragraph.alignment),
"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 _get_cell_style(cell) -> dict:
paragraph = cell.paragraphs[0] if cell.paragraphs else None
font_info = _get_run_font_info(paragraph) if paragraph is not None else {
"name": None,
"eastAsia": None,
"size": None,
"bold": False,
"italic": False,
"color": "000000",
}
return {
"font": font_info,
"shading": None,
"alignment": _alignment_name(paragraph.alignment) if paragraph is not None else "LEFT",
"borders": {"top": None, "bottom": None, "left": None, "right": None},
}
def _extract_table_data(table: Table) -> dict:
rows = len(table.rows)
cols = max((len(row.cells) for row in table.rows), default=0)
grid_span: dict[str, int] = {}
cell_styles: list[dict] = []
matrix: list[list[str]] = []
for row_index, row in enumerate(table.rows):
row_values: list[str] = []
for col_index, cell in enumerate(row.cells):
text = "\n".join(paragraph.text.strip() for paragraph in cell.paragraphs if paragraph.text.strip())
row_values.append(text)
tc_pr = cell._tc.tcPr
grid_span_value = None
if tc_pr is not None and tc_pr.gridSpan is not None:
grid_span_value = tc_pr.gridSpan.val
if grid_span_value:
grid_span[f"{row_index}-{col_index}"] = int(grid_span_value)
cell_styles.append(_get_cell_style(cell))
matrix.append(row_values)
return {
"rows": rows,
"cols": cols,
"gridSpan": grid_span,
"cellStyles": cell_styles,
"tableWidth": None,
"data": matrix,
}
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_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):
text = block.text.strip()
if not text:
continue
level = _heading_level(block.style.name if block.style is not None else "")
if level is not None:
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=current_heading_style_json,
is_table=False,
table_json="{}",
write_mode="replace_heading_only",
block_type="heading",
edit_mode="manual",
output_format="text",
))
continue
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:
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_heading is None:
loose_table_count += 1
anchor_title = f"表格_{loose_table_count}"
title = anchor_title
write_mode = "replace_section"
else:
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