| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399 |
- """
- 提取 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()
|