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