如何批量删除word文档中存在的无用样式

一、引子

前段时间收到了其他专业提交过来的报告,我一打开,差点给我 6 年的电脑给干冒烟了。

打开样式一看,嚯,那样式多得跟不要钱一样,统计了一下。

莱莱滴😡,发报告你给我发好的喂!

于是乎开始苦逼地找删除样式的办法,下面链接里提到了 2 个方法。

Word 文档中存在大量样式(Word 里样式太多)

方法一是在样式管理器里编辑,不过删除数量太多 Word 会崩溃。方法二 XML 文件替换法其实还挺好的,但是我应用的时候发现我这边情况有亿点复杂:

  1. 大几百页的报告,我根本不知道哪些样式是有用的(歪头摆手);

  2. 而且有的样式,你别看他本身没应用实例,但基于该样式的其他样式是有应用实例的,数量还不少,你说气人不气人;

  3. 最关键的一点,我想专门发个文水下字数,你都有现成的办法了,我吃什么啊。

没我上述这些问题的,看到这里就可以了,因为下面我要开始装逼了:

二、问题拆解

上面提到的方法二其实已经提供了思路,我们可以直接对 XML 文件进行操作嘛。下面我做如下部署,打火机一下:

  1. 遍历所有样式,先找出有应用实例的样式,予以保留;

  2. 对于上面予以保留的样式,向上寻找其各自基于的父样式,以及父样式基于的爷样式,以及爷样式基于的太爷样式,以及......

  3. 反选一下,移除所有不保留的样式。

三、代码

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太好用了你知道吗!

相关推荐
晓晨的博客1 小时前
word表格其中一行填写内容后跳到下一页
经验分享
万联WANFLOW1 小时前
外贸独立站:服务器放海外国内后台卡,放国内海外客户打不开——这道两头难怎么破?
运维·网络·数据库·经验分享
5G微创业1 小时前
Python / Node.js 调用短视频去水印 API 完整示例(含 SDK)
python·node.js·音视频·api·sdk·短视频
金海境科技2 小时前
【服务器数据恢复】H3C CAS虚拟化平台HP存储qcow2文件损坏快照丢失数据恢复案例
服务器·数据库·经验分享
cszn20262 小时前
AI如何自动识别投标函废标风险?智能评审项目实践
经验分享
phltxy2 小时前
LangGraph智能租房助手实践
大数据·人工智能·python·深度学习·语言模型·langchain
码银2 小时前
放弃了豆包,我使用Python做了一个桌面宠物
python·microsoft·宠物
测功机之家2 小时前
国内口碑好的测功机公司找哪家
python·物联网
xlrqx2 小时前
2026年平顶山家电清洗培训设备是否齐全依据多因素来判断
大数据·python