styles.py 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229
  1. """
  2. 提取 Word 文档中所有样式的完整 XML 定义(不遗漏任何属性)。
  3. 同时提取编号格式定义(numbering.xml)以支持标题编号。
  4. 目标文件: default.docx
  5. """
  6. import json
  7. from pathlib import Path
  8. from docx import Document
  9. from docx.oxml.ns import qn
  10. from lxml import etree
  11. from zipfile import ZipFile
  12. DOC_PATH = Path(__file__).parent / "default.docx"
  13. OUTPUT_PATH = Path(__file__).parent / "default.json"
  14. def emu_to_pt(emu) -> float | None:
  15. """EMU 转磅(1 pt = 12700 EMU)"""
  16. if emu is None:
  17. return None
  18. return round(int(emu) / 12700, 2)
  19. def extract_font(style) -> dict | None:
  20. """通过 API 提取字体信息(作为便捷摘要)"""
  21. try:
  22. f = style.font
  23. color_rgb = None
  24. try:
  25. if f.color and f.color.type:
  26. color_rgb = str(f.color.rgb)
  27. except Exception:
  28. pass
  29. return {
  30. "name": f.name,
  31. "size_pt": emu_to_pt(f.size),
  32. "bold": f.bold,
  33. "italic": f.italic,
  34. "underline": f.underline,
  35. "color_rgb": color_rgb,
  36. "strike": f.strike,
  37. "all_caps": f.all_caps,
  38. "small_caps": f.small_caps,
  39. }
  40. except Exception:
  41. return None
  42. def extract_paragraph_format(style) -> dict | None:
  43. """通过 API 提取段落格式(作为便捷摘要)"""
  44. try:
  45. pf = style.paragraph_format
  46. return {
  47. "alignment": str(pf.alignment) if pf.alignment else None,
  48. "left_indent_pt": emu_to_pt(pf.left_indent),
  49. "right_indent_pt": emu_to_pt(pf.right_indent),
  50. "first_line_indent_pt": emu_to_pt(pf.first_line_indent),
  51. "space_before_pt": emu_to_pt(pf.space_before),
  52. "space_after_pt": emu_to_pt(pf.space_after),
  53. "line_spacing": float(pf.line_spacing) if pf.line_spacing else None,
  54. "keep_together": pf.keep_together,
  55. "keep_with_next": pf.keep_with_next,
  56. "page_break_before": pf.page_break_before,
  57. }
  58. except Exception:
  59. return None
  60. def element_to_dict(elem: etree._Element) -> dict | list | str | None:
  61. """
  62. 将 lxml Element 转换为字典,完整保留标签、属性、文本和子元素。
  63. 处理重复子元素(转为列表)。
  64. 使用 '{namespace}localname' 格式作为标签名。
  65. """
  66. # 标签名(完整 Clark 表示法)
  67. tag = elem.tag
  68. # 属性字典
  69. attrib = dict(elem.attrib)
  70. # 子元素处理
  71. children = list(elem)
  72. if children:
  73. # 子元素可能重复,使用字典存储列表
  74. child_dict = {}
  75. for child in children:
  76. child_tag = child.tag
  77. child_val = element_to_dict(child)
  78. if child_tag in child_dict:
  79. # 相同标签名出现多次,转为列表
  80. if not isinstance(child_dict[child_tag], list):
  81. child_dict[child_tag] = [child_dict[child_tag]]
  82. child_dict[child_tag].append(child_val)
  83. else:
  84. child_dict[child_tag] = child_val
  85. # 合并文本:如果存在文本(非空白),作为 '#text' 字段
  86. text = elem.text.strip() if elem.text else None
  87. tail = elem.tail.strip() if elem.tail else None
  88. result = {"@tag": tag, "@attrib": attrib, "@children": child_dict}
  89. if text:
  90. result["#text"] = text
  91. if tail:
  92. result["#tail"] = tail
  93. return result
  94. else:
  95. # 叶子节点:直接返回文本或属性+文本
  96. text = elem.text.strip() if elem.text else None
  97. tail = elem.tail.strip() if elem.tail else None
  98. if attrib or text or tail:
  99. result = {"@tag": tag, "@attrib": attrib}
  100. if text:
  101. result["#text"] = text
  102. if tail:
  103. result["#tail"] = tail
  104. return result
  105. else:
  106. # 完全空的元素,可简化为 None 但保持结构
  107. return {"@tag": tag, "@attrib": attrib}
  108. def extract_style_full_xml(style) -> dict:
  109. """提取样式的完整 XML 定义(转换为字典)"""
  110. elem = style.element
  111. if elem is None:
  112. return None
  113. # 整个样式元素转换为字典
  114. style_dict = element_to_dict(elem)
  115. return style_dict
  116. def extract_styles(doc_path: Path) -> list[dict]:
  117. doc = Document(str(doc_path))
  118. styles_data = []
  119. for style in doc.styles:
  120. info = {
  121. "name": style.name,
  122. "style_id": style.style_id,
  123. "type": str(style.type),
  124. "builtin": style.builtin,
  125. "hidden": style.hidden,
  126. "quick_style": style.quick_style,
  127. "priority": style.priority,
  128. "base_style": (
  129. style.base_style.name
  130. if hasattr(style, "base_style") and style.base_style
  131. else None
  132. ),
  133. "next_paragraph_style": (
  134. style.next_paragraph_style.name
  135. if hasattr(style, "next_paragraph_style") and style.next_paragraph_style
  136. else None
  137. ),
  138. "font_summary": extract_font(style), # 便捷摘要
  139. "paragraph_format_summary": extract_paragraph_format(style), # 便捷摘要
  140. "full_xml_definition": extract_style_full_xml(style) # 完整原始定义
  141. }
  142. styles_data.append(info)
  143. return styles_data
  144. def extract_numbering(doc_path: Path) -> dict | None:
  145. """
  146. 提取 numbering.xml 的完整内容(编号格式定义)
  147. 返回字典格式,如果文档中没有编号则返回 None
  148. """
  149. try:
  150. with ZipFile(str(doc_path), 'r') as docx_zip:
  151. # 尝试读取 numbering.xml
  152. try:
  153. numbering_xml = docx_zip.read('word/numbering.xml')
  154. except KeyError:
  155. # 文档中没有编号定义
  156. return None
  157. # 解析 XML 并转换为字典
  158. root = etree.fromstring(numbering_xml)
  159. numbering_dict = element_to_dict(root)
  160. return numbering_dict
  161. except Exception as e:
  162. print(f"警告: 提取编号格式失败: {e}")
  163. return None
  164. def main():
  165. print(f"读取文件: {DOC_PATH}")
  166. if not DOC_PATH.exists():
  167. raise FileNotFoundError(f"文件不存在: {DOC_PATH}")
  168. # 提取样式
  169. styles_data = extract_styles(DOC_PATH)
  170. # 提取编号格式
  171. numbering_data = extract_numbering(DOC_PATH)
  172. result = {
  173. "source_file": DOC_PATH.name,
  174. "total_styles": len(styles_data),
  175. "styles": styles_data,
  176. "numbering": numbering_data, # 新增:编号格式定义
  177. }
  178. OUTPUT_PATH.write_text(
  179. json.dumps(result, ensure_ascii=False, indent=2),
  180. encoding="utf-8",
  181. )
  182. print(f"共提取 {len(styles_data)} 个样式")
  183. if numbering_data:
  184. print(f"✅ 已提取编号格式定义")
  185. else:
  186. print(f"ℹ️ 文档中没有编号格式")
  187. print(f"完整定义已保存至: {OUTPUT_PATH}")
  188. # 打印摘要
  189. by_type: dict[str, list[str]] = {}
  190. for s in styles_data:
  191. t = s["type"]
  192. by_type.setdefault(t, []).append(s["name"])
  193. print("\n--- 样式类型分布 ---")
  194. for t, names in by_type.items():
  195. print(f" {t}: {len(names)} 个")
  196. if __name__ == "__main__":
  197. main()