简介:这份资源面向需要用Python自动化处理Word文档的开发者与办公自动化爱好者,聚焦于从.docx文件中批量导出内嵌图片与OLE嵌入文件这一具体问题。包内共2033个文件,以908个py源码、880个pyc字节码为主,辅以C扩展头文件、pyd动态库、exe可执行文件及少量docx示例文档,压缩包约12.42MB,并附带虚拟环境与IDE配置,开箱即可运行调试。实现思路上,借助docx库遍历段落、表格等元素定位图片,解析关系文件获取图片数据并落盘;对嵌入的Excel、PDF等对象,则通过oletools等库解析OLE结构完成提取,脚本分工清晰,便于二次改造。已有1197人学习下载,适合希望掌握文档深度解析、批量迁移与内容整理技巧的读者参考借鉴。
1. 批量提取 Word 文档里的图片和嵌入文件,Python 这条路走得通
做过文档迁移的同行大概都遇到过这种需求:手头几十上百个.docx文件,里面散落着产品图、截图、附件表格,业务方要你把它们全部抽出来归档。手动右键另存?一个文件还能忍,一百个就是纯体力活。这个项目的定位很明确——用 Python 把 Word 文档里的图片和嵌入对象批量导出,适合需要做文档资产整理、内容迁移或二次开发的开发者。它不依赖 Office 软件本身,纯代码解析.docx包结构,跑在服务器上也没问题。技术栈就是 Python 标准库zipfile加xml解析,配合python-docx处理文档关系映射,轻量、可嵌入、好改造。下面从目录结构一路拆到核心代码,再讲怎么改。
2. 项目结构与技术选型:为什么不用 COM 接口
2.1 目录布局与模块职责
这个项目的代码量不大,但分层清晰,适合直接嵌进现有工程。典型目录结构如下:
word-extractor/ ├── extractor/ │ ├── __init__.py │ ├── core.py # 主提取逻辑,协调各模块 │ ├── media.py # 图片资源定位与导出 │ ├── embedded.py # 嵌入文件(OLE对象)处理 │ └── rels.py # 关系映射解析 ├── utils/ │ ├── file_utils.py # 文件读写、目录创建 │ └── logger.py # 日志配置 ├── config.py # 输出路径、命名规则等配置 ├── main.py # 命令行入口 └── requirements.txtcore.py是调度中心,负责打开.docx、调用关系解析、分发到media.py或embedded.py。rels.py单独抽出来是因为.docx内部的关系映射(word/_rels/document.xml.rels)决定了每个资源文件的真实路径和类型,这是整个提取流程的关键索引。config.py集中管理输出目录、文件名模板、是否保留原始目录层级等参数,改起来不用翻业务代码。
2.2 技术选型:zipfile + lxml 而非 COM
很多人第一反应是用win32com调 Word 的 COM 接口,让 Office 自己另存。这条路在 Windows 桌面环境下能跑,但问题很明显:必须装 Office、启动慢、并发差、服务器上基本没法用。这个项目选了另一条路——.docx本质上就是一个 ZIP 包,图片放在word/media/下,嵌入文件放在word/embeddings/下,关系映射写在word/_rels/document.xml.rels里。用zipfile直接读包,用lxml解析 XML,不依赖任何外部软件。
import zipfile from lxml import etree def open_docx(path): """打开 docx 文件,返回 zip 对象和关系映射字典""" zf = zipfile.ZipFile(path, 'r') # 读取关系映射文件,建立 rId -> 目标路径的索引 rels_xml = zf.read('word/_rels/document.xml.rels') root = etree.fromstring(rels_xml) rels_map = {} for rel in root: rid = rel.get('Id') target = rel.get('Target') rel_type = rel.get('Type') rels_map[rid] = {'target': target, 'type': rel_type} return zf, rels_map这段代码是整个提取流程的入口。zipfile.ZipFile以只读模式打开文档,zf.read直接读取内部 XML 而不解压到磁盘。rels_map的键是关系 ID(如rId5),值包含目标路径和类型。类型字段用来区分是图片、嵌入文件还是超链接——图片的 Type 通常以/image结尾,嵌入对象的 Type 包含/oleObject。拿到这个映射后,后续就能根据文档正文里引用的rId反查真实文件路径。
注意:
.doc老格式不是 ZIP 包,这套方法只适用于.docx。如果手头有.doc,需要先批量转成.docx,常见做法是用 LibreOffice 的无头模式转换。
2.3 依赖清单与版本约束
requirements.txt里核心依赖只有两个:lxml用于 XML 解析,python-docx用于高层文档遍历。python-docx本身也依赖lxml,所以实际安装时版本要统一。我一般会锁定lxml>=4.9和python-docx>=0.8.11,这两个版本组合在 Python 3.8 到 3.11 上都验证过。如果只需要底层提取,其实可以完全不用python-docx,直接用zipfile+lxml就够了,少一层依赖少一个坑。
3. 核心提取逻辑:从关系映射到文件落盘
3.1 图片提取:定位 media 目录并导出
图片提取的关键是两步:先从关系映射里筛出所有图片类型的关系,再根据 Target 路径从 ZIP 包里读出二进制流写盘。media.py里的核心函数如下:
import os from utils.file_utils import ensure_dir IMAGE_TYPES = ('image/png', 'image/jpeg', 'image/gif', 'image/bmp') def extract_images(zf, rels_map, output_dir): """从 docx 中提取所有图片到 output_dir""" ensure_dir(output_dir) count = 0 for rid, info in rels_map.items(): # 只处理图片类型的关系 if not any(t in info['type'] for t in IMAGE_TYPES): continue target = info['target'] # Target 可能是相对路径,需要拼上 word/ 前缀 if not target.startswith('word/'): target = 'word/' + target.lstrip('/') try: data = zf.read(target) except KeyError: continue # 关系存在但文件缺失,跳过 # 用原始文件名保存,避免重名加 rId 前缀 filename = f"{rid}_{os.path.basename(target)}" with open(os.path.join(output_dir, filename), 'wb') as f: f.write(data) count += 1 return countIMAGE_TYPES定义了支持的图片 MIME 类型,实际项目中可能还需要加image/tiff或image/webp。info['type']是完整的关系类型 URI,用in做子串匹配比精确匹配更稳,因为不同 Word 版本生成的 URI 前缀可能略有差异。target字段在关系文件里通常是相对word/的路径,比如media/image1.png,所以需要补全前缀。zf.read如果遇到关系存在但实际文件缺失的情况会抛KeyError,这里直接跳过而不是中断整个流程——批量处理时一个坏文件不应该拖垮全部任务。文件名用rId加原始文件名,既保留了顺序信息又避免不同图片重名覆盖。
3.2 嵌入文件提取:OLE 对象与包对象
嵌入文件比图片复杂一些,因为 Word 里插入的 Excel 表格、PDF 附件、另一个 Word 文档,在底层可能以 OLE 对象或包对象的形式存在。它们的共同点是都放在word/embeddings/目录下,但关系类型不同。
EMBED_TYPES = ('oleObject', 'package') def extract_embedded(zf, rels_map, output_dir): """提取嵌入的 OLE 对象和包对象""" ensure_dir(output_dir) results = [] for rid, info in rels_map.items(): if not any(t in info['type'] for t in EMBED_TYPES): continue target = info['target'] if not target.startswith('word/'): target = 'word/' + target.lstrip('/') try: data = zf.read(target) except KeyError: continue # 嵌入文件的原始扩展名藏在 oleObject 的 progId 里 # 这里先用 bin 后缀保存,后续可根据 progId 重命名 filename = f"{rid}_{os.path.basename(target)}" with open(os.path.join(output_dir, filename), 'wb') as f: f.write(data) results.append({'rid': rid, 'file': filename, 'size': len(data)}) return resultsEMBED_TYPES覆盖了oleObject和package两种关系类型。嵌入文件在 ZIP 包里的实际文件名通常是oleObject1.bin、oleObject2.bin这种,没有扩展名。要还原成可打开的文件,需要进一步解析word/embeddings/下对应的 XML 描述文件,从中读取progId(如Excel.Sheet.12)来推断真实格式。这个项目里先以.bin保存,把重命名逻辑留给二次开发按需实现——因为不同业务对嵌入文件的处理策略差异很大,有的要还原成 Excel,有的只要原始二进制归档。
3.3 关系映射解析:rId 与真实路径的桥梁
rels.py负责把关系映射解析成更易用的结构,同时处理一些边界情况,比如外部链接(Target 是 URL 而非内部路径)和图片在页眉页脚中的引用。
def build_resource_index(rels_map): """把关系映射按类型分组,返回 {type: [资源列表]}""" index = {'images': [], 'embedded': [], 'others': []} for rid, info in rels_map.items(): target = info['target'] # 跳过外部链接,只处理包内资源 if target.startswith('http') or target.startswith('mailto'): continue rel_type = info['type'] if 'image' in rel_type: index['images'].append({'rid': rid, 'target': target}) elif 'oleObject' in rel_type or 'package' in rel_type: index['embedded'].append({'rid': rid, 'target': target}) else: index['others'].append({'rid': rid, 'target': target}) return index这个索引结构让上层调用更清晰:core.py拿到index后直接按类别分发,不用在每个提取函数里重复判断类型。跳过http和mailto开头的外部链接很重要,否则会尝试从 ZIP 包里读一个不存在的路径而报错。others类别里可能包含超链接、批注引用等,当前项目不处理,但保留结构方便扩展。
4. 避坑与常见问题排查
4.1 提取出来图片打不开或只有几 KB
现象:导出的 PNG 文件大小异常,用看图软件打开提示损坏。原因通常是关系映射里的 Target 路径没有正确补全前缀,导致读到了错误的文件,或者读的是缩略图而非原图。.docx里图片可能同时存在原图和缩略图两个关系,缩略图在word/media/下但尺寸很小。解决:在extract_images里加一层过滤,检查文件大小,小于 1KB 的跳过;同时打印每个 Target 的实际路径,确认补全逻辑正确。
4.2 嵌入的 Excel 文件导出后无法打开
现象:oleObject1.bin改成.xlsx后 Excel 报格式错误。原因是 OLE 对象外面包了一层复合文档结构,不是直接的 xlsx 二进制。解决:需要解析word/embeddings/下对应的.xml描述文件,读取progId和shapeId,再用olefile库剥离外层容器。如果业务只要求归档原始数据,保持.bin后缀不强行改扩展名反而更安全。
4.3 批量处理时某个文件报错导致整个任务中断
现象:跑一百个文档,跑到第 37 个抛异常,后面全部没处理。原因是zipfile或lxml遇到损坏文件时抛出的异常没有被捕获。解决:在main.py的循环里对每个文件单独 try-except,记录失败文件名和异常信息到日志,继续处理下一个。批量任务的铁律是单个失败不影响整体,最后统一汇总失败列表。
4.4 中文文件名乱码
现象:导出的图片文件名里中文变成乱码。原因是 ZIP 包内文件名编码在不同 Word 版本下不一致,zipfile默认用 CP437 解码。解决:在ZipFile构造时传入metadata_encoding='utf-8'(Python 3.11+ 支持),或者手动对namelist()里的文件名做编码转换。更稳妥的做法是导出时不用原始文件名,统一用rId加序号命名,彻底绕开编码问题。
4.5 页眉页脚里的图片提取不到
现象:正文图片都导出了,但页眉里的 Logo 没出来。原因是页眉页脚有独立的关系映射文件,路径在word/_rels/header1.xml.rels等位置,不在主文档的document.xml.rels里。解决:遍历word/_rels/下所有.rels文件,合并关系映射后再统一提取。这个改动不大,但能覆盖更完整的场景。
5. 二次开发改造:从能跑到好用
5.1 按文档结构还原图片位置
当前实现是把所有图片平铺到一个目录,实际业务里往往需要知道每张图在文档中的位置——比如按章节归档,或者生成一份「图片-段落」对照表。改造思路是解析word/document.xml,遍历所有<w:drawing>节点,从中提取r:embed属性拿到rId,再结合段落文本和标题层级建立映射。
from lxml import etree NS = { 'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main', 'r': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships', 'a': 'http://schemas.openxmlformats.org/drawingml/2006/main' } def map_images_to_paragraphs(zf): """返回 [(段落文本, rId), ...] 的映射列表""" doc_xml = zf.read('word/document.xml') root = etree.fromstring(doc_xml) result = [] for para in root.iter('{http://schemas.openxmlformats.org/wordprocessingml/2006/main}p'): text = ''.join(para.itertext()).strip() for drawing in para.iter('{http://schemas.openxmlformats.org/wordprocessingml/2006/main}drawing'): for blip in drawing.iter('{http://schemas.openxmlformats.org/drawingml/2006/main}blip'): rid = blip.get('{http://schemas.openxmlformats.org/officeDocument/2006/relationships}embed') if rid: result.append((text[:50], rid)) return result这段代码遍历文档所有段落,对每个段落查找内嵌的drawing节点,再从中提取blip元素的r:embed属性。text[:50]截取段落前 50 个字符作为位置标识,避免长段落撑爆输出。拿到这个映射后,导出图片时就可以按段落文本生成子目录,或者输出一份 CSV 索引。
5.2 输出格式与命名策略的可配置化
不同项目对输出结构要求不同:有的要按原始media目录结构保留,有的要按扩展名分目录,有的要加时间戳前缀。建议在config.py里定义命名策略函数,而不是硬编码在提取逻辑里。
| 配置项 | 可选值 | 说明 |
|---|---|---|
naming | rid/original/index | 文件名生成规则 |
subdir_by | none/ext/type | 是否按扩展名或类型建子目录 |
keep_structure | True/False | 是否保留原始 media 目录层级 |
overwrite | True/False | 同名文件是否覆盖 |
我一般会把naming默认设为rid,因为原始文件名在批量场景下重名率极高,rid加序号最稳。subdir_by设为ext在图片和嵌入文件混在一起时特别有用,导出后一眼就能看出哪些是图、哪些是附件。
5.3 验证提取完整性的一个笨办法
改完代码怎么确认没漏?我的习惯是对比 ZIP 包内word/media/和word/embeddings/下的文件总数与导出文件数。写个校验函数:
def verify_extraction(zf, output_dir): """对比包内资源数与导出数""" internal = [n for n in zf.namelist() if n.startswith('word/media/') or n.startswith('word/embeddings/')] exported = [f for f in os.listdir(output_dir) if os.path.isfile(os.path.join(output_dir, f))] print(f"包内资源: {len(internal)}, 已导出: {len(exported)}") if len(internal) != len(exported): missing = set(os.path.basename(n) for n in internal) - set(exported) print(f"可能遗漏: {missing}")这个校验不判断内容是否正确,只保证数量对齐。数量对不上时,missing集合能直接指出哪些文件没导出来,再去查对应的关系映射就能定位问题。从那以后我每次改完提取逻辑,都强制走一遍这个校验,比肉眼翻目录靠谱得多。希望帮到你。
本文还有配套的精品资源,点击获取