""" 提取 Word 文档中所有样式的完整 XML 定义(不遗漏任何属性)。 同时提取编号格式定义(numbering.xml)以支持标题编号。 同时提取页面设置(页边距、纸张大小、方向、页眉页脚等)。 目标文件: default.docx """ import json from pathlib import Path from docx import Document from docx.oxml.ns import qn from lxml import etree from zipfile import ZipFile DOC_PATH = Path(__file__).parent / "default.docx" OUTPUT_PATH = Path(__file__).parent / "default.json" def emu_to_pt(emu) -> float | None: """EMU 转磅(1 pt = 12700 EMU)""" if emu is None: return None return round(int(emu) / 12700, 2) def emu_to_twips(emu) -> int | None: """EMU 转 twips (1 twips = 635 EMU)""" if emu is None: return None return int(emu / 635) def extract_font(style) -> dict | None: """通过 API 提取字体信息(作为便捷摘要)""" try: f = style.font color_rgb = None try: if f.color and f.color.type: color_rgb = str(f.color.rgb) except Exception: pass return { "name": f.name, "size_pt": emu_to_pt(f.size), "bold": f.bold, "italic": f.italic, "underline": f.underline, "color_rgb": color_rgb, "strike": f.strike, "all_caps": f.all_caps, "small_caps": f.small_caps, } except Exception: return None def extract_paragraph_format(style) -> dict | None: """通过 API 提取段落格式(作为便捷摘要)""" try: pf = style.paragraph_format return { "alignment": str(pf.alignment) if pf.alignment else None, "left_indent_pt": emu_to_pt(pf.left_indent), "right_indent_pt": emu_to_pt(pf.right_indent), "first_line_indent_pt": emu_to_pt(pf.first_line_indent), "space_before_pt": emu_to_pt(pf.space_before), "space_after_pt": emu_to_pt(pf.space_after), "line_spacing": float(pf.line_spacing) if pf.line_spacing else None, "keep_together": pf.keep_together, "keep_with_next": pf.keep_with_next, "page_break_before": pf.page_break_before, } except Exception: return None def element_to_dict(elem: etree._Element) -> dict | list | str | None: """ 将 lxml Element 转换为字典,完整保留标签、属性、文本和子元素。 处理重复子元素(转为列表)。 使用 '{namespace}localname' 格式作为标签名。 """ # 标签名(完整 Clark 表示法) tag = elem.tag # 属性字典 attrib = dict(elem.attrib) # 子元素处理 children = list(elem) if children: # 子元素可能重复,使用字典存储列表 child_dict = {} for child in children: child_tag = child.tag child_val = element_to_dict(child) if child_tag in child_dict: # 相同标签名出现多次,转为列表 if not isinstance(child_dict[child_tag], list): child_dict[child_tag] = [child_dict[child_tag]] child_dict[child_tag].append(child_val) else: child_dict[child_tag] = child_val # 合并文本:如果存在文本(非空白),作为 '#text' 字段 text = elem.text.strip() if elem.text else None tail = elem.tail.strip() if elem.tail else None result = {"@tag": tag, "@attrib": attrib, "@children": child_dict} if text: result["#text"] = text if tail: result["#tail"] = tail return result else: # 叶子节点:直接返回文本或属性+文本 text = elem.text.strip() if elem.text else None tail = elem.tail.strip() if elem.tail else None if attrib or text or tail: result = {"@tag": tag, "@attrib": attrib} if text: result["#text"] = text if tail: result["#tail"] = tail return result else: # 完全空的元素,可简化为 None 但保持结构 return {"@tag": tag, "@attrib": attrib} def extract_style_full_xml(style) -> dict: """提取样式的完整 XML 定义(转换为字典)""" elem = style.element if elem is None: return None # 整个样式元素转换为字典 style_dict = element_to_dict(elem) return style_dict def extract_styles(doc_path: Path) -> list[dict]: doc = Document(str(doc_path)) styles_data = [] for style in doc.styles: info = { "name": style.name, "style_id": style.style_id, "type": str(style.type), "builtin": style.builtin, "hidden": style.hidden, "quick_style": style.quick_style, "priority": style.priority, "base_style": ( style.base_style.name if hasattr(style, "base_style") and style.base_style else None ), "next_paragraph_style": ( style.next_paragraph_style.name if hasattr(style, "next_paragraph_style") and style.next_paragraph_style else None ), "font_summary": extract_font(style), # 便捷摘要 "paragraph_format_summary": extract_paragraph_format(style), # 便捷摘要 "full_xml_definition": extract_style_full_xml(style) # 完整原始定义 } styles_data.append(info) return styles_data def extract_numbering(doc_path: Path) -> dict | None: """ 提取 numbering.xml 的完整内容(编号格式定义) 返回字典格式,如果文档中没有编号则返回 None """ try: with ZipFile(str(doc_path), 'r') as docx_zip: # 尝试读取 numbering.xml try: numbering_xml = docx_zip.read('word/numbering.xml') except KeyError: # 文档中没有编号定义 return None # 解析 XML 并转换为字典 root = etree.fromstring(numbering_xml) numbering_dict = element_to_dict(root) return numbering_dict except Exception as e: print(f"警告: 提取编号格式失败: {e}") return None def infer_paper_size(width_twips: int, height_twips: int) -> str: """根据页面尺寸推断纸张类型""" # 常见纸张尺寸(twips) # A4 (纵向): 210mm x 297mm = 11906 x 16838 twips # A4 (横向): 297mm x 210mm = 16838 x 11906 twips # Letter (纵向): 8.5" x 11" = 12240 x 15840 twips # A3 (纵向): 297mm x 420mm = 16838 x 23811 twips # 允许 ±100 twips 的误差 tolerance = 100 # A4 纵向 if abs(width_twips - 11906) < tolerance and abs(height_twips - 16838) < tolerance: return "A4" # A4 横向 elif abs(width_twips - 16838) < tolerance and abs(height_twips - 11906) < tolerance: return "A4 (Landscape)" # Letter 纵向 elif abs(width_twips - 12240) < tolerance and abs(height_twips - 15840) < tolerance: return "Letter" # A3 纵向 elif abs(width_twips - 16838) < tolerance and abs(height_twips - 23811) < tolerance: return "A3" else: return "Custom" def extract_page_setup(doc_path: Path) -> dict: """ 提取页面设置信息(页边距、纸张大小、方向、页眉页脚等) 只提取第一个节(section)的设置(大多数文档只有一个节) """ try: doc = Document(str(doc_path)) # 检查是否有 section if not doc.sections: return {"sections": []} # 只提取第一个 section section = doc.sections[0] sections_data = [] # 提取页边距(转换为 twips) top_margin = emu_to_twips(section.top_margin) bottom_margin = emu_to_twips(section.bottom_margin) left_margin = emu_to_twips(section.left_margin) right_margin = emu_to_twips(section.right_margin) gutter = emu_to_twips(section.gutter) # 提取纸张尺寸(转换为 twips) page_width = emu_to_twips(section.page_width) page_height = emu_to_twips(section.page_height) # 提取方向 # orientation: 0 = PORTRAIT, 1 = LANDSCAPE orientation = "portrait" if section.orientation == 0 else "landscape" # 提取页眉页脚距离(转换为 twips) header_distance = emu_to_twips(section.header_distance) footer_distance = emu_to_twips(section.footer_distance) # 提取首页不同设置 different_first_page = section.different_first_page_header_footer # 从 XML 提取文档网格设置 # 注意:需要通过 section._sectPr 访问 XML 元素 grid_type = None chars_per_line = None lines_per_page = None try: # 尝试获取 section 的 XML 元素 if hasattr(section, '_sectPr'): sectPr = section._sectPr elif hasattr(section, 'element'): sectPr = section.element else: sectPr = None if sectPr is not None: docGrid = sectPr.find(qn('w:docGrid')) if docGrid is not None: # 网格类型: default, lines, linesAndChars, snapToChars grid_type = docGrid.get(qn('w:type')) # linePitch: 每行的高度(用于计算行数) # charSpace: 字符间距 line_pitch = docGrid.get(qn('w:linePitch')) char_space = docGrid.get(qn('w:charSpace')) # 注意:Word UI 显示的"每页行数"对应 linePitch # "每行字符数"对应 charSpace if line_pitch: lines_per_page = int(line_pitch) if char_space: chars_per_line = int(char_space) except Exception as e: # 如果提取网格失败,继续(网格不是必需的) pass # 推断纸张大小 paper_size_inferred = infer_paper_size(page_width, page_height) section_data = { # 页边距(twips) "top_margin": top_margin, "bottom_margin": bottom_margin, "left_margin": left_margin, "right_margin": right_margin, "gutter": gutter, # 纸张(twips) "page_width": page_width, "page_height": page_height, "orientation": orientation, # 版式(twips) "header_distance": header_distance, "footer_distance": footer_distance, "different_first_page": different_first_page, # 文档网格(可选) "grid_type": grid_type, "chars_per_line": chars_per_line, "lines_per_page": lines_per_page, # 推断信息 "paper_size_inferred": paper_size_inferred, } sections_data.append(section_data) return { "sections": sections_data } except Exception as e: print(f"警告: 提取页面设置失败: {e}") import traceback traceback.print_exc() return { "sections": [] } def main(): print(f"读取文件: {DOC_PATH}") if not DOC_PATH.exists(): raise FileNotFoundError(f"文件不存在: {DOC_PATH}") # 提取样式 styles_data = extract_styles(DOC_PATH) # 提取编号格式 numbering_data = extract_numbering(DOC_PATH) # 提取页面设置 page_setup_data = extract_page_setup(DOC_PATH) result = { "source_file": DOC_PATH.name, "total_styles": len(styles_data), "styles": styles_data, "numbering": numbering_data, # 编号格式定义 "page_setup": page_setup_data, # 页面设置 } OUTPUT_PATH.write_text( json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8", ) print(f"共提取 {len(styles_data)} 个样式") if numbering_data: print(f"✅ 已提取编号格式定义") else: print(f"ℹ️ 文档中没有编号格式") # 打印页面设置摘要 if page_setup_data and page_setup_data.get("sections"): section = page_setup_data["sections"][0] print(f"✅ 已提取页面设置:") print(f" - 纸张: {section.get('paper_size_inferred')} ({section.get('orientation')})") print(f" - 页边距: 上{section.get('top_margin')} 下{section.get('bottom_margin')} " f"左{section.get('left_margin')} 右{section.get('right_margin')} twips") if section.get('grid_type'): print(f" - 文档网格: {section.get('grid_type')} " f"(每行{section.get('chars_per_line')}字符, 每页{section.get('lines_per_page')}行)") print(f"完整定义已保存至: {OUTPUT_PATH}") # 打印样式类型分布 by_type: dict[str, list[str]] = {} for s in styles_data: t = s["type"] by_type.setdefault(t, []).append(s["name"]) print("\n--- 样式类型分布 ---") for t, names in by_type.items(): print(f" {t}: {len(names)} 个") if __name__ == "__main__": main()