styles.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399
  1. """
  2. 提取 Word 文档中所有样式的完整 XML 定义(不遗漏任何属性)。
  3. 同时提取编号格式定义(numbering.xml)以支持标题编号。
  4. 同时提取页面设置(页边距、纸张大小、方向、页眉页脚等)。
  5. 目标文件: default.docx
  6. """
  7. import json
  8. from pathlib import Path
  9. from docx import Document
  10. from docx.oxml.ns import qn
  11. from lxml import etree
  12. from zipfile import ZipFile
  13. DOC_PATH = Path(__file__).parent / "default.docx"
  14. OUTPUT_PATH = Path(__file__).parent / "default.json"
  15. def emu_to_pt(emu) -> float | None:
  16. """EMU 转磅(1 pt = 12700 EMU)"""
  17. if emu is None:
  18. return None
  19. return round(int(emu) / 12700, 2)
  20. def emu_to_twips(emu) -> int | None:
  21. """EMU 转 twips (1 twips = 635 EMU)"""
  22. if emu is None:
  23. return None
  24. return int(emu / 635)
  25. def extract_font(style) -> dict | None:
  26. """通过 API 提取字体信息(作为便捷摘要)"""
  27. try:
  28. f = style.font
  29. color_rgb = None
  30. try:
  31. if f.color and f.color.type:
  32. color_rgb = str(f.color.rgb)
  33. except Exception:
  34. pass
  35. return {
  36. "name": f.name,
  37. "size_pt": emu_to_pt(f.size),
  38. "bold": f.bold,
  39. "italic": f.italic,
  40. "underline": f.underline,
  41. "color_rgb": color_rgb,
  42. "strike": f.strike,
  43. "all_caps": f.all_caps,
  44. "small_caps": f.small_caps,
  45. }
  46. except Exception:
  47. return None
  48. def extract_paragraph_format(style) -> dict | None:
  49. """通过 API 提取段落格式(作为便捷摘要)"""
  50. try:
  51. pf = style.paragraph_format
  52. return {
  53. "alignment": str(pf.alignment) if pf.alignment else None,
  54. "left_indent_pt": emu_to_pt(pf.left_indent),
  55. "right_indent_pt": emu_to_pt(pf.right_indent),
  56. "first_line_indent_pt": emu_to_pt(pf.first_line_indent),
  57. "space_before_pt": emu_to_pt(pf.space_before),
  58. "space_after_pt": emu_to_pt(pf.space_after),
  59. "line_spacing": float(pf.line_spacing) if pf.line_spacing else None,
  60. "keep_together": pf.keep_together,
  61. "keep_with_next": pf.keep_with_next,
  62. "page_break_before": pf.page_break_before,
  63. }
  64. except Exception:
  65. return None
  66. def element_to_dict(elem: etree._Element) -> dict | list | str | None:
  67. """
  68. 将 lxml Element 转换为字典,完整保留标签、属性、文本和子元素。
  69. 处理重复子元素(转为列表)。
  70. 使用 '{namespace}localname' 格式作为标签名。
  71. """
  72. # 标签名(完整 Clark 表示法)
  73. tag = elem.tag
  74. # 属性字典
  75. attrib = dict(elem.attrib)
  76. # 子元素处理
  77. children = list(elem)
  78. if children:
  79. # 子元素可能重复,使用字典存储列表
  80. child_dict = {}
  81. for child in children:
  82. child_tag = child.tag
  83. child_val = element_to_dict(child)
  84. if child_tag in child_dict:
  85. # 相同标签名出现多次,转为列表
  86. if not isinstance(child_dict[child_tag], list):
  87. child_dict[child_tag] = [child_dict[child_tag]]
  88. child_dict[child_tag].append(child_val)
  89. else:
  90. child_dict[child_tag] = child_val
  91. # 合并文本:如果存在文本(非空白),作为 '#text' 字段
  92. text = elem.text.strip() if elem.text else None
  93. tail = elem.tail.strip() if elem.tail else None
  94. result = {"@tag": tag, "@attrib": attrib, "@children": child_dict}
  95. if text:
  96. result["#text"] = text
  97. if tail:
  98. result["#tail"] = tail
  99. return result
  100. else:
  101. # 叶子节点:直接返回文本或属性+文本
  102. text = elem.text.strip() if elem.text else None
  103. tail = elem.tail.strip() if elem.tail else None
  104. if attrib or text or tail:
  105. result = {"@tag": tag, "@attrib": attrib}
  106. if text:
  107. result["#text"] = text
  108. if tail:
  109. result["#tail"] = tail
  110. return result
  111. else:
  112. # 完全空的元素,可简化为 None 但保持结构
  113. return {"@tag": tag, "@attrib": attrib}
  114. def extract_style_full_xml(style) -> dict:
  115. """提取样式的完整 XML 定义(转换为字典)"""
  116. elem = style.element
  117. if elem is None:
  118. return None
  119. # 整个样式元素转换为字典
  120. style_dict = element_to_dict(elem)
  121. return style_dict
  122. def extract_styles(doc_path: Path) -> list[dict]:
  123. doc = Document(str(doc_path))
  124. styles_data = []
  125. for style in doc.styles:
  126. info = {
  127. "name": style.name,
  128. "style_id": style.style_id,
  129. "type": str(style.type),
  130. "builtin": style.builtin,
  131. "hidden": style.hidden,
  132. "quick_style": style.quick_style,
  133. "priority": style.priority,
  134. "base_style": (
  135. style.base_style.name
  136. if hasattr(style, "base_style") and style.base_style
  137. else None
  138. ),
  139. "next_paragraph_style": (
  140. style.next_paragraph_style.name
  141. if hasattr(style, "next_paragraph_style") and style.next_paragraph_style
  142. else None
  143. ),
  144. "font_summary": extract_font(style), # 便捷摘要
  145. "paragraph_format_summary": extract_paragraph_format(style), # 便捷摘要
  146. "full_xml_definition": extract_style_full_xml(style) # 完整原始定义
  147. }
  148. styles_data.append(info)
  149. return styles_data
  150. def extract_numbering(doc_path: Path) -> dict | None:
  151. """
  152. 提取 numbering.xml 的完整内容(编号格式定义)
  153. 返回字典格式,如果文档中没有编号则返回 None
  154. """
  155. try:
  156. with ZipFile(str(doc_path), 'r') as docx_zip:
  157. # 尝试读取 numbering.xml
  158. try:
  159. numbering_xml = docx_zip.read('word/numbering.xml')
  160. except KeyError:
  161. # 文档中没有编号定义
  162. return None
  163. # 解析 XML 并转换为字典
  164. root = etree.fromstring(numbering_xml)
  165. numbering_dict = element_to_dict(root)
  166. return numbering_dict
  167. except Exception as e:
  168. print(f"警告: 提取编号格式失败: {e}")
  169. return None
  170. def infer_paper_size(width_twips: int, height_twips: int) -> str:
  171. """根据页面尺寸推断纸张类型"""
  172. # 常见纸张尺寸(twips)
  173. # A4 (纵向): 210mm x 297mm = 11906 x 16838 twips
  174. # A4 (横向): 297mm x 210mm = 16838 x 11906 twips
  175. # Letter (纵向): 8.5" x 11" = 12240 x 15840 twips
  176. # A3 (纵向): 297mm x 420mm = 16838 x 23811 twips
  177. # 允许 ±100 twips 的误差
  178. tolerance = 100
  179. # A4 纵向
  180. if abs(width_twips - 11906) < tolerance and abs(height_twips - 16838) < tolerance:
  181. return "A4"
  182. # A4 横向
  183. elif abs(width_twips - 16838) < tolerance and abs(height_twips - 11906) < tolerance:
  184. return "A4 (Landscape)"
  185. # Letter 纵向
  186. elif abs(width_twips - 12240) < tolerance and abs(height_twips - 15840) < tolerance:
  187. return "Letter"
  188. # A3 纵向
  189. elif abs(width_twips - 16838) < tolerance and abs(height_twips - 23811) < tolerance:
  190. return "A3"
  191. else:
  192. return "Custom"
  193. def extract_page_setup(doc_path: Path) -> dict:
  194. """
  195. 提取页面设置信息(页边距、纸张大小、方向、页眉页脚等)
  196. 只提取第一个节(section)的设置(大多数文档只有一个节)
  197. """
  198. try:
  199. doc = Document(str(doc_path))
  200. # 检查是否有 section
  201. if not doc.sections:
  202. return {"sections": []}
  203. # 只提取第一个 section
  204. section = doc.sections[0]
  205. sections_data = []
  206. # 提取页边距(转换为 twips)
  207. top_margin = emu_to_twips(section.top_margin)
  208. bottom_margin = emu_to_twips(section.bottom_margin)
  209. left_margin = emu_to_twips(section.left_margin)
  210. right_margin = emu_to_twips(section.right_margin)
  211. gutter = emu_to_twips(section.gutter)
  212. # 提取纸张尺寸(转换为 twips)
  213. page_width = emu_to_twips(section.page_width)
  214. page_height = emu_to_twips(section.page_height)
  215. # 提取方向
  216. # orientation: 0 = PORTRAIT, 1 = LANDSCAPE
  217. orientation = "portrait" if section.orientation == 0 else "landscape"
  218. # 提取页眉页脚距离(转换为 twips)
  219. header_distance = emu_to_twips(section.header_distance)
  220. footer_distance = emu_to_twips(section.footer_distance)
  221. # 提取首页不同设置
  222. different_first_page = section.different_first_page_header_footer
  223. # 从 XML 提取文档网格设置
  224. # 注意:需要通过 section._sectPr 访问 XML 元素
  225. grid_type = None
  226. chars_per_line = None
  227. lines_per_page = None
  228. try:
  229. # 尝试获取 section 的 XML 元素
  230. if hasattr(section, '_sectPr'):
  231. sectPr = section._sectPr
  232. elif hasattr(section, 'element'):
  233. sectPr = section.element
  234. else:
  235. sectPr = None
  236. if sectPr is not None:
  237. docGrid = sectPr.find(qn('w:docGrid'))
  238. if docGrid is not None:
  239. # 网格类型: default, lines, linesAndChars, snapToChars
  240. grid_type = docGrid.get(qn('w:type'))
  241. # linePitch: 每行的高度(用于计算行数)
  242. # charSpace: 字符间距
  243. line_pitch = docGrid.get(qn('w:linePitch'))
  244. char_space = docGrid.get(qn('w:charSpace'))
  245. # 注意:Word UI 显示的"每页行数"对应 linePitch
  246. # "每行字符数"对应 charSpace
  247. if line_pitch:
  248. lines_per_page = int(line_pitch)
  249. if char_space:
  250. chars_per_line = int(char_space)
  251. except Exception as e:
  252. # 如果提取网格失败,继续(网格不是必需的)
  253. pass
  254. # 推断纸张大小
  255. paper_size_inferred = infer_paper_size(page_width, page_height)
  256. section_data = {
  257. # 页边距(twips)
  258. "top_margin": top_margin,
  259. "bottom_margin": bottom_margin,
  260. "left_margin": left_margin,
  261. "right_margin": right_margin,
  262. "gutter": gutter,
  263. # 纸张(twips)
  264. "page_width": page_width,
  265. "page_height": page_height,
  266. "orientation": orientation,
  267. # 版式(twips)
  268. "header_distance": header_distance,
  269. "footer_distance": footer_distance,
  270. "different_first_page": different_first_page,
  271. # 文档网格(可选)
  272. "grid_type": grid_type,
  273. "chars_per_line": chars_per_line,
  274. "lines_per_page": lines_per_page,
  275. # 推断信息
  276. "paper_size_inferred": paper_size_inferred,
  277. }
  278. sections_data.append(section_data)
  279. return {
  280. "sections": sections_data
  281. }
  282. except Exception as e:
  283. print(f"警告: 提取页面设置失败: {e}")
  284. import traceback
  285. traceback.print_exc()
  286. return {
  287. "sections": []
  288. }
  289. def main():
  290. print(f"读取文件: {DOC_PATH}")
  291. if not DOC_PATH.exists():
  292. raise FileNotFoundError(f"文件不存在: {DOC_PATH}")
  293. # 提取样式
  294. styles_data = extract_styles(DOC_PATH)
  295. # 提取编号格式
  296. numbering_data = extract_numbering(DOC_PATH)
  297. # 提取页面设置
  298. page_setup_data = extract_page_setup(DOC_PATH)
  299. result = {
  300. "source_file": DOC_PATH.name,
  301. "total_styles": len(styles_data),
  302. "styles": styles_data,
  303. "numbering": numbering_data, # 编号格式定义
  304. "page_setup": page_setup_data, # 页面设置
  305. }
  306. OUTPUT_PATH.write_text(
  307. json.dumps(result, ensure_ascii=False, indent=2),
  308. encoding="utf-8",
  309. )
  310. print(f"共提取 {len(styles_data)} 个样式")
  311. if numbering_data:
  312. print(f"✅ 已提取编号格式定义")
  313. else:
  314. print(f"ℹ️ 文档中没有编号格式")
  315. # 打印页面设置摘要
  316. if page_setup_data and page_setup_data.get("sections"):
  317. section = page_setup_data["sections"][0]
  318. print(f"✅ 已提取页面设置:")
  319. print(f" - 纸张: {section.get('paper_size_inferred')} ({section.get('orientation')})")
  320. print(f" - 页边距: 上{section.get('top_margin')} 下{section.get('bottom_margin')} "
  321. f"左{section.get('left_margin')} 右{section.get('right_margin')} twips")
  322. if section.get('grid_type'):
  323. print(f" - 文档网格: {section.get('grid_type')} "
  324. f"(每行{section.get('chars_per_line')}字符, 每页{section.get('lines_per_page')}行)")
  325. print(f"完整定义已保存至: {OUTPUT_PATH}")
  326. # 打印样式类型分布
  327. by_type: dict[str, list[str]] = {}
  328. for s in styles_data:
  329. t = s["type"]
  330. by_type.setdefault(t, []).append(s["name"])
  331. print("\n--- 样式类型分布 ---")
  332. for t, names in by_type.items():
  333. print(f" {t}: {len(names)} 个")
  334. if __name__ == "__main__":
  335. main()