225 lines
7.4 KiB
Python
225 lines
7.4 KiB
Python
import json
|
|
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
|
|
|
|
|
|
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,
|
|
}
|
|
|
|
|
|
def parse_template(file_path: str) -> list[ParsedParagraph]:
|
|
document = Document(file_path)
|
|
parsed: list[ParsedParagraph] = []
|
|
current_item: ParsedParagraph | None = None
|
|
loose_table_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_item = ParsedParagraph(
|
|
sort_index=len(parsed) + 1,
|
|
anchor_title=text,
|
|
title=text,
|
|
content="",
|
|
style_json=json.dumps(_capture_paragraph_style(block, level), ensure_ascii=False),
|
|
is_table=False,
|
|
table_json="{}",
|
|
write_mode="replace_section",
|
|
)
|
|
parsed.append(current_item)
|
|
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)
|
|
else:
|
|
current_item.content = "\n".join(filter(None, [current_item.content, text]))
|
|
else:
|
|
table_data = _extract_table_data(block)
|
|
table_text = f"[表格] {table_data['rows']} 行 {table_data['cols']} 列"
|
|
if current_item 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)
|
|
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]))
|
|
|
|
return parsed
|