一、引子
前段时间收到了其他专业提交过来的报告,我一打开,差点给我 6 年的电脑给干冒烟了。
打开样式一看,嚯,那样式多得跟不要钱一样,统计了一下。

莱莱滴😡,发报告你给我发好的喂!
于是乎开始苦逼地找删除样式的办法,下面链接里提到了 2 个方法。
方法一是在样式管理器里编辑,不过删除数量太多 Word 会崩溃。方法二 XML 文件替换法其实还挺好的,但是我应用的时候发现我这边情况有亿点复杂:
大几百页的报告,我根本不知道哪些样式是有用的(歪头摆手);
而且有的样式,你别看他本身没应用实例,但基于该样式的其他样式是有应用实例的,数量还不少,你说气人不气人;
最关键的一点,我想专门发个文水下字数,你都有现成的办法了,我吃什么啊。
没我上述这些问题的,看到这里就可以了,因为下面我要开始装逼了:
二、问题拆解
上面提到的方法二其实已经提供了思路,我们可以直接对 XML 文件进行操作嘛。下面我做如下部署,打火机一下:
遍历所有样式,先找出有应用实例的样式,予以保留;
对于上面予以保留的样式,向上寻找其各自基于的父样式,以及父样式基于的爷样式,以及爷样式基于的太爷样式,以及......
反选一下,移除所有不保留的样式。
三、代码
3.1 主体函数
python
"""
清理 DOCX 中未使用的样式(含隐藏样式)。
保留规则:
1. 样式自身在文档中被直接引用(段落/字符/表格/编号样式等)。
2. 该样式被另一个已保留样式的 basedOn 链(嵌套)所引用。
输出:
- 清理后的 DOCX 文档
- XLSX 统计报表
"""
import argparse
import os
import re
import shutil
import zipfile
from collections import defaultdict, Counter
from copy import deepcopy
from datetime import datetime
from pathlib import Path
from lxml import etree
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
# ↓OpenXML 国际标准(ECMA-376 / ISO/IEC 29500)中定义的命名空间 URI,Word 2007 及以后版本生成的 .docx 都遵循这个标准
W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
W = "{%s}" % W_NS
# 样式类型显示名
STYLE_TYPE_NAMES = {
"paragraph": "段落",
"character": "字符",
"table": "表格",
"numbering": "编号",
"defaultParagraphFont": "默认字体",
"docDefaults": "文档默认",
}
def qname(tag):
return W + tag
def get_attr(elem, attr, ns=None):
"""获取带命名空间的属性值。"""
if ns is None:
ns = W_NS
return elem.get("{%s}%s" % (ns, attr))
class StyleCleaner:
def __init__(self, input_path: str, output_path: str, report_path: str):
self.input_path = Path(input_path)
self.output_path = Path(output_path)
self.report_path = Path(report_path)
self.styles_xml = None
self.styles_root = None
self.styles_by_id = {}
self.all_style_ids = set()
self.based_on = {} # style_id -> based_on_id
self.direct_refs = Counter() # style_id -> 文档中直接引用次数
self.used_set = set() # 最终保留的样式集合
self.deleted_set = set() # 最终删除的样式集合
self.style_info = {} # style_id -> 元信息
self.content_xml_files = [] # 文档内容 XML 路径列表
def _parse_styles(self):
"""解析 styles.xml,建立样式索引。"""
with zipfile.ZipFile(self.input_path, "r") as z:
self.styles_xml = z.read("word/styles.xml")
self.styles_root = etree.fromstring(self.styles_xml)
for style in self.styles_root.findall(qname("style")):
sid = get_attr(style, "styleId")
stype = get_attr(style, "type") or ""
hidden = style.find(qname("hidden")) is not None
based_on_elem = style.find(qname("basedOn"))
based_on_val = get_attr(based_on_elem, "val") if based_on_elem is not None else None
# 获取默认名(w:name)
name_elem = style.find(qname("name"))
display_name = get_attr(name_elem, "val") if name_elem is not None else ""
self.styles_by_id[sid] = style
self.all_style_ids.add(sid)
self.based_on[sid] = based_on_val
self.style_info[sid] = {
"styleId": sid,
"type": stype,
"type_name": STYLE_TYPE_NAMES.get(stype, stype),
"display_name": display_name,
"hidden": hidden,
"based_on": based_on_val,
}
def _collect_content_xml_files(self, z: zipfile.ZipFile):
"""收集所有可能引用样式的内容 XML 文件路径。"""
files = []
# 文档主体
for name in ["word/document.xml", "word/footnotes.xml", "word/endnotes.xml", "word/comments.xml", "word/numbering.xml"]:
if name in z.namelist():
files.append(name)
# 页眉页脚
for name in z.namelist():
if name.startswith("word/header") or name.startswith("word/footer"):
files.append(name)
self.content_xml_files = files
def _scan_direct_refs(self):
"""扫描所有内容 XML 中直接引用样式的位置。"""
# 样式引用属性对应的标签
ref_attrs = {
"pStyle": None, # 段落样式
"rStyle": None, # 字符样式
"tblStyle": None, # 表格样式
"numStyle": None, # 编号样式(numbering.xml 中)
"styleLink": None, # 编号样式链接
}
with zipfile.ZipFile(self.input_path, "r") as z:
self._collect_content_xml_files(z)
for fname in self.content_xml_files:
xml = z.read(fname)
root = etree.fromstring(xml)
for tag, _ in ref_attrs.items():
for elem in root.iter(qname(tag)):
val = get_attr(elem, "val")
if val:
self.direct_refs[val] += 1
def _resolve_used_set(self):
"""根据直接引用和 basedOn 链计算需要保留的样式集合。"""
used = set()
# 1. 直接引用的样式
for sid, count in self.direct_refs.items():
if sid in self.styles_by_id and count > 0:
used.add(sid)
# 2. 被已用样式 basedOn 的样式(嵌套向上追溯)
def trace_based_on(sid):
base = self.based_on.get(sid)
if base and base in self.styles_by_id and base not in used:
used.add(base)
trace_based_on(base)
for sid in list(used):
trace_based_on(sid)
self.used_set = used
self.deleted_set = self.all_style_ids - used
def _remove_unused_styles(self):
"""从 styles.xml 中移除未使用的样式节点。"""
for sid in list(self.deleted_set):
style = self.styles_by_id.get(sid)
if style is not None:
self.styles_root.remove(style)
def _build_report_data(self):
"""构建报表数据。"""
rows = []
for sid in sorted(self.all_style_ids, key=lambda x: x.lower()):
info = self.style_info[sid]
rows.append({
"styleId": sid,
"type": info["type_name"],
"display_name": info["display_name"],
"hidden": "是" if info["hidden"] else "否",
"direct_refs": self.direct_refs.get(sid, 0),
"based_on": (info["based_on"] or "").strip(),
"deleted": "是" if sid in self.deleted_set else "否",
})
return rows
def _write_report(self, rows):
"""生成 XLSX 统计报表。"""
wb = Workbook()
ws = wb.active
ws.title = "样式清理统计"
headers = ["样式ID", "样式类型", "显示名称", "是否隐藏", "应用实例数量", "基于样式", "是否删除"]
ws.append(headers)
for row in rows:
ws.append([
row["styleId"],
row["type"],
row["display_name"],
row["hidden"],
row["direct_refs"],
row["based_on"],
row["deleted"],
])
# 样式美化
header_font = Font(bold=True, color="FFFFFF")
header_fill = PatternFill("solid", fgColor="366092")
thin_border = Border(
left=Side(style="thin"), right=Side(style="thin"),
top=Side(style="thin"), bottom=Side(style="thin")
)
for col in range(1, len(headers) + 1):
cell = ws.cell(row=1, column=col)
cell.font = header_font
cell.fill = header_fill
cell.alignment = Alignment(horizontal="center", vertical="center")
cell.border = thin_border
for row_idx in range(2, ws.max_row + 1):
for col_idx in range(1, len(headers) + 1):
cell = ws.cell(row=row_idx, column=col_idx)
cell.border = thin_border
cell.alignment = Alignment(horizontal="left", vertical="center")
# 删除行标红
if ws.cell(row=row_idx, column=len(headers)).value == "是":
cell.fill = PatternFill("solid", fgColor="FFE6E6")
# 列宽
ws.column_dimensions["A"].width = 25
ws.column_dimensions["B"].width = 12
ws.column_dimensions["C"].width = 30
ws.column_dimensions["D"].width = 12
ws.column_dimensions["E"].width = 16
ws.column_dimensions["F"].width = 25
ws.column_dimensions["G"].width = 12
# 添加汇总信息
summary_row = ws.max_row + 2
ws.cell(row=summary_row, column=1, value="汇总")
ws.cell(row=summary_row, column=1).font = Font(bold=True)
ws.cell(row=summary_row + 1, column=1, value="原始样式总数")
ws.cell(row=summary_row + 1, column=2, value=len(self.all_style_ids))
ws.cell(row=summary_row + 2, column=1, value="保留样式数")
ws.cell(row=summary_row + 2, column=2, value=len(self.used_set))
ws.cell(row=summary_row + 3, column=1, value="删除样式数")
ws.cell(row=summary_row + 3, column=2, value=len(self.deleted_set))
ws.cell(row=summary_row + 4, column=1, value="生成时间")
ws.cell(row=summary_row + 4, column=2, value=datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
wb.save(self.report_path)
def _save_docx(self):
"""将修改后的 styles.xml 写回新的 docx(避免 zip 重复条目)。"""
new_styles_xml = etree.tostring(
self.styles_root, xml_declaration=True, encoding="UTF-8", standalone=False
)
with zipfile.ZipFile(self.input_path, "r") as zin:
with zipfile.ZipFile(self.output_path, "w", zipfile.ZIP_DEFLATED) as zout:
for item in zin.infolist():
data = zin.read(item.filename)
if item.filename == "word/styles.xml":
data = new_styles_xml
zout.writestr(item, data)
def run(self):
print(f"[1/4] 解析样式定义:{self.input_path}")
self._parse_styles()
print(f" 共发现 {len(self.all_style_ids)} 个样式")
print("[2/4] 扫描文档中的样式引用...")
self._scan_direct_refs()
used_direct = sum(1 for s, c in self.direct_refs.items() if s in self.styles_by_id and c > 0)
print(f" 直接引用的样式:{used_direct} 个")
print("[3/4] 计算可删除样式...")
self._resolve_used_set()
print(f" 保留 {len(self.used_set)} 个,删除 {len(self.deleted_set)} 个")
print("[4/4] 生成清理后的文档和报表...")
self._remove_unused_styles()
self._save_docx()
rows = self._build_report_data()
self._write_report(rows)
print(f"\n完成:")
print(f" 清理后的文档:{self.output_path}")
print(f" 统计报表:{self.report_path}")
def clean_styles(input_path: str, output_path: str = None, report_path: str = None):
"""
Args:
input_path: 输入 docx 文件路径
output_path: 输出 docx 文件路径,默认在输入文件同目录生成 *_已清理.docx
report_path: 输出 xlsx 报表路径,默认在输入文件同目录生成 *_样式统计报表.xlsx
Returns:
StyleCleaner 实例,包含 used_set / deleted_set / direct_refs 等统计信息
"""
input_path_obj = Path(input_path).resolve()
if not input_path_obj.exists():
raise FileNotFoundError(f"输入文件不存在:{input_path_obj}")
output_path = output_path or input_path_obj.parent / (input_path_obj.stem + "_已清理" + input_path_obj.suffix)
report_path = report_path or input_path_obj.parent / (input_path_obj.stem + "_样式统计报表.xlsx")
cleaner = StyleCleaner(str(input_path_obj), str(output_path), str(report_path))
cleaner.run()
return cleaner
3.2 调用方法
python
input_docx = 你需要处理的文档路径 #形如: input_docx = r"D:\workfile\测试文档.docx"
# output_docx = 指定输出文件路径 #可选
# report_xlsx = 指定输出报表路径 #可选
cleaner = clean_styles(input_docx) #调用方法,不指定output_docx和report_xlsx路径的话会自动生成
# cleaner = clean_styles(input_docx,output_docx,output_docx,report_xlsx)
3.3 输出结果

我这边为了省事,只给了需要处理文件的路径,清理后的文档和统计报表按照自动生成的路径(和原文件同一文件夹下),报表长下面这样:

最后,特别鸣谢deepseek,AI太好用了你知道吗!