Python批量导出Word图片与嵌入文件:解析docx结构实战
简介这份资源面向需要用 Python 批量处理 Word 文档的开发者与办公自动化爱好者聚焦于从 .docx 文件中导出全部图片与嵌入对象如 Excel 表格、PDF 等 OLE 文件解决手动逐份提取效率低、易遗漏的问题。包内共 2033 个文件以 908 个 py 源码与 880 个 pyc 字节码为主辅以 h、pxd、pyd 等编译与扩展文件以及 txt、md、html 说明文档和少量 docx 示例、png 图片、exe 工具压缩包约 12.42MB整体是一套可直接运行的完整工程。核心逻辑围绕 docx 库遍历段落、表格与关系文件定位图片再借助 olefile、oletools 解析 OLE 结构提取嵌入文件docx_export.py 与 main.py 分别承担导出逻辑与主流程调度venv 与 .idea 目录则体现虚拟环境与 IDE 配置的工程化组织。已有 1197 人学习下载适合希望掌握文档解析、批量资源抽取与自动化整理思路的读者参考复用。1. 导出 Word 里的图片和嵌入文件为什么手动右键另存迟早会翻车一份 200 页的技术方案文档里面嵌了 47 张架构图、12 个 Excel 附件、8 个 PDF 参考文件。某天需要把所有图片和附件单独抽出来归档你打开 Word 开始右键图片、另存为、命名、下一张。半小时后你发现有些图片是浮动式环绕右键根本没有“另存为图片”选项有些附件藏在 OLE 对象里双击才能打开还有些图片是链接式引用本地根本没有嵌入。这就是手动操作的死穴——Word 文档的内部结构远比表面看到的复杂。用 Python 实现 Word 文档中图片和嵌入文件的批量导出核心思路是绕过 Word 界面直接解析 .docx 文件的 ZIP 容器结构。.docx 本质上是一个 ZIP 包图片存在word/media/目录下嵌入的 OLE 对象存在word/embeddings/目录下而它们与正文的对应关系记录在word/_rels/document.xml.rels和word/document.xml里。理解这层结构之后用 Python 的zipfile和python-docx就能把导出这件事做成一条可复现的流水线。这篇内容适合需要批量处理 Word 文档的运维、文档工程师和数据整理人员下面从文件结构讲起一步步落到可运行的脚本和参数调优。2. 拆开 .docx 看内部图片和嵌入文件到底藏在哪2.1 用 zipfile 列出 docx 的全部内部条目.docx 文件可以直接用解压工具打开但更可控的方式是用 Python 的zipfile模块在内存中遍历。先跑一段探查代码看清楚一个典型文档里到底有哪些东西。import zipfile docx_path sample.docx with zipfile.ZipFile(docx_path, r) as z: # 列出所有内部条目及其压缩后大小 for info in z.infolist(): print(f{info.filename:60s} {info.file_size:10d} bytes)这段代码的输出会揭示几个关键目录。word/media/下是文档中所有图片文件命名通常是image1.png、image2.jpeg这种顺序编号。word/embeddings/下是嵌入的 OLE 对象常见的有oleObject1.bin、Microsoft_Excel_Worksheet1.xlsx等。word/_rels/document.xml.rels是关系映射文件记录了每个资源在文档中的 rId 和对应的目标路径。word/document.xml是正文内容图片和嵌入对象通过 rId 在这里被引用。参数说明infolist()返回的是ZipInfo对象列表file_size是解压后大小compress_size是压缩后大小。如果file_size为 0说明这个条目是空目录或占位符导出时需要跳过。2.2 解析 rels 关系文件建立 rId 到实际文件的映射光知道word/media/下有图片还不够你需要知道哪张图对应文档里的哪个位置、哪个附件是哪个 OLE 对象。这就要解析word/_rels/document.xml.rels。import zipfile from lxml import etree NS {r: http://schemas.openxmlformats.org/package/2006/relationships} def parse_rels(docx_path): 解析 document.xml.rels返回 rId - (type, target) 的字典 rels_map {} with zipfile.ZipFile(docx_path, r) as z: with z.open(word/_rels/document.xml.rels) as f: tree etree.parse(f) for rel in tree.findall(r:Relationship, NS): rid rel.get(Id) rtype rel.get(Type) target rel.get(Target) rels_map[rid] (rtype, target) return rels_map rels parse_rels(sample.docx) for rid, (rtype, target) in rels.items(): # 只关注图片和 OLE 对象 if image in rtype or oleObject in rtype or package in rtype: print(f{rid}: {rtype.split(/)[-1]:20s} - {target})这段代码的关键在于命名空间。document.xml.rels的根元素使用了 OOXML 的关系命名空间XPath 查询时必须带上r:前缀。Type属性的末尾部分标识资源类型常见的有image、oleObject、package、hyperlink。Target是相对路径图片通常是media/image1.pngOLE 对象是embeddings/oleObject1.bin。参数说明etree.parse()直接接受文件对象不需要先读成字符串。如果文档的 rels 文件很大可以用iterparse做流式解析但一般文档的 rels 不会超过几百条直接解析足够。2.3 从 document.xml 提取图片的显示尺寸和嵌入位置有些场景下你不仅要把图片导出来还需要知道它在文档中的显示尺寸比如用于还原排版。这些信息记录在word/document.xml的wp:extent元素里。NS_W { w: http://schemas.openxmlformats.org/wordprocessingml/2006/main, wp: http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing, a: http://schemas.openxmlformats.org/drawingml/2006/main, r: http://schemas.openxmlformats.org/officeDocument/2006/relationships, } def extract_image_info(docx_path): 提取文档中所有图片的 rId、显示宽高EMU和替代文本 results [] with zipfile.ZipFile(docx_path, r) as z: with z.open(word/document.xml) as f: tree etree.parse(f) # 查找所有 drawing 元素 for drawing in tree.iter({http://schemas.openxmlformats.org/wordprocessingml/2006/main}drawing): # 提取 extent显示尺寸单位 EMU extent drawing.find(.//wp:extent, NS_W) # 提取 blip 的 rId blip drawing.find(.//a:blip, NS_W) # 提取替代文本 docPr drawing.find(.//wp:docPr, NS_W) if blip is not None: rid blip.get({http://schemas.openxmlformats.org/officeDocument/2006/relationships}embed) width_emu int(extent.get(cx)) if extent is not None else 0 height_emu int(extent.get(cy)) if extent is not None else 0 alt_text docPr.get(descr, ) if docPr is not None else results.append({ rid: rid, width_emu: width_emu, height_emu: height_emu, alt_text: alt_text, }) return resultsEMU 是 English Metric Unit1 厘米等于 360000 EMU1 英寸等于 914400 EMU。导出时如果需要按原始显示尺寸还原可以用width_emu / 914400得到英寸值。descr属性是图片的替代文本很多文档作者会在这里写图片说明导出时一并保留对后续检索很有用。参数说明tree.iter()会遍历所有匹配的元素包括嵌套在文本框、页眉页脚里的图片。如果只想处理正文部分可以先用 XPath 定位到w:body再遍历。blip的embed属性对应的是图片的 rId如果是链接式图片则用link属性但链接式图片在本地导出时无法获取实际文件需要额外处理。3. 用 Python 批量导出图片和嵌入文件从脚本到可复用工具3.1 导出图片的最小可用脚本把前面几段代码串起来就是一个能跑的图片导出脚本。核心逻辑是遍历 rels 映射找到所有 image 类型的资源从 ZIP 中读取二进制数据写入磁盘。import os import zipfile from lxml import etree NS {r: http://schemas.openxmlformats.org/package/2006/relationships} def export_images(docx_path, output_dir): 导出 docx 中所有图片到指定目录 os.makedirs(output_dir, exist_okTrue) count 0 with zipfile.ZipFile(docx_path, r) as z: # 解析 rels with z.open(word/_rels/document.xml.rels) as f: tree etree.parse(f) for rel in tree.findall(r:Relationship, NS): rtype rel.get(Type) target rel.get(Target) # 只处理图片类型 if not rtype.endswith(/image): continue # target 是相对路径需要拼上 word/ 前缀 zip_path fword/{target} if zip_path not in z.namelist(): print(f[跳过] 找不到: {zip_path}) continue # 读取二进制并写入 data z.read(zip_path) filename os.path.basename(target) out_path os.path.join(output_dir, filename) with open(out_path, wb) as img_f: img_f.write(data) count 1 print(f[导出] {filename} ({len(data)} bytes)) print(f共导出 {count} 张图片到 {output_dir}) return count export_images(sample.docx, output/images)这段代码的逻辑说明rtype.endswith(/image)是判断资源类型的可靠方式因为完整的 Type 是http://schemas.openxmlformats.org/officeDocument/2006/relationships/image。target在 rels 中是相对于word/目录的路径所以拼接时要加word/前缀。z.namelist()检查确保目标文件确实存在避免因文档损坏导致异常。参数说明output_dir建议用绝对路径或相对于脚本的路径避免因工作目录变化导致文件写错位置。如果文档中有重名图片不同 rId 指向同一文件当前脚本会覆盖后续可以加去重逻辑。3.2 导出嵌入的 OLE 对象和附件嵌入文件的导出逻辑与图片类似但类型判断和文件扩展名处理不同。OLE 对象的 Type 通常是oleObject或packageTarget 指向embeddings/目录。import os import zipfile from lxml import etree NS {r: http://schemas.openxmlformats.org/package/2006/relationships} # 常见 OLE 对象的扩展名映射 OLE_EXT_MAP { Excel.Sheet.12: .xlsx, Excel.Sheet.8: .xls, Word.Document.12: .docx, Word.Document.8: .doc, PowerPoint.Show.12: .pptx, AcroExch.Document: .pdf, } def export_embeddings(docx_path, output_dir): 导出 docx 中所有嵌入的 OLE 对象和附件 os.makedirs(output_dir, exist_okTrue) count 0 with zipfile.ZipFile(docx_path, r) as z: with z.open(word/_rels/document.xml.rels) as f: tree etree.parse(f) for rel in tree.findall(r:Relationship, NS): rtype rel.get(Type) target rel.get(Target) # 匹配 OLE 对象和 package 类型 if not (rtype.endswith(/oleObject) or rtype.endswith(/package)): continue zip_path fword/{target} if zip_path not in z.namelist(): print(f[跳过] 找不到: {zip_path}) continue data z.read(zip_path) # 尝试从文件名推断扩展名 base os.path.basename(target) ext os.path.splitext(base)[1] if not ext or ext .bin: # 对于 .bin 文件尝试从内容头判断 ext guess_ole_ext(data) out_name fembed_{count 1}{ext} out_path os.path.join(output_dir, out_name) with open(out_path, wb) as f_out: f_out.write(data) count 1 print(f[导出] {out_name} ({len(data)} bytes)) print(f共导出 {count} 个嵌入文件到 {output_dir}) return count def guess_ole_ext(data): 根据文件头魔数猜测 OLE 对象的实际类型 if data[:4] bPK\x03\x04: return .zip # 可能是 xlsx/docx/pptx本质是 ZIP if data[:4] b%PDF: return .pdf if data[:8] b\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1: return .doc # 旧版 OLE 复合文档 return .bin逻辑说明OLE 对象的 Target 通常是embeddings/oleObject1.bin但实际内容可能是 Excel 表格、PDF 或旧版 Word 文档。guess_ole_ext通过文件头魔数做初步判断PK\x03\x04是 ZIP 格式的标识说明这个 OLE 对象本质是一个 OOXML 文件xlsx/docx/pptx可以进一步用zipfile打开验证。\xd0\xcf\x11\xe0是旧版 OLE 复合文档的标识对应 .doc/.xls 格式。参数说明OLE_EXT_MAP中的键是 OLE 对象的 ProgID但 rels 文件中通常不直接暴露 ProgID需要解析word/embeddings/下的oleObject1.bin内部结构才能获取。对于大多数导出场景用文件头魔数判断已经够用。3.3 保留原始文件名和目录结构实际文档中嵌入的 Excel 附件往往有有意义的文件名比如“2024年Q1数据.xlsx”。这些信息记录在 OLE 对象的复合文档结构中提取起来比较麻烦。一个折中方案是从document.xml中提取 OLE 对象的显示名称。def extract_ole_display_names(docx_path): 从 document.xml 中提取 OLE 对象的显示名称 NS_W { w: http://schemas.openxmlformats.org/wordprocessingml/2006/main, o: urn:schemas-microsoft-com:office:office, r: http://schemas.openxmlformats.org/officeDocument/2006/relationships, } names {} with zipfile.ZipFile(docx_path, r) as z: with z.open(word/document.xml) as f: tree etree.parse(f) # 查找所有 OLE 对象 for ole in tree.iter({urn:schemas-microsoft-com:office:office}OLEObject): rid ole.get({http://schemas.openxmlformats.org/officeDocument/2006/relationships}id) prog_id ole.get(ProgID, ) shape_id ole.get(ShapeID, ) if rid: names[rid] {prog_id: prog_id, shape_id: shape_id} return names这段代码提取的是 OLE 对象的 ProgID如Excel.Sheet.12和 ShapeID。ProgID 可以用来推断文件类型ShapeID 可以用于关联显示名称。但要注意OLE 对象的原始文件名通常存储在复合文档的\x05SummaryInformation流中需要额外的解析库如olefile才能读取。如果对文件名没有强需求用embed_1.xlsx这种编号命名更简单可靠。参数说明ProgID属性在o:OLEObject元素上命名空间是urn:schemas-microsoft-com:office:office。ShapeID用于关联v:shape元素中的显示文本但提取链路较长实际项目中按需实现。4. 避坑指南导出 Word 资源时最容易翻车的五个地方4.1 图片导出后打不开文件头是乱的现象导出的图片用看图软件打开显示损坏或者文件大小正常但内容全是乱码。原因最常见的情况是把word/media/下的文件当成了纯图片但实际上某些“图片”是 EMF 或 WMF 矢量格式或者是 TIFF 多页文件。另一种情况是文档中的图片是链接式引用word/media/下根本没有对应文件脚本从错误路径读取了数据。解决导出前先检查文件头魔数。PNG 是\x89PNGJPEG 是\xff\xd8\xffGIF 是GIF8BMP 是BMEMF 是\x01\x00\x00\x00。如果魔数不匹配说明文件类型判断有误。对于链接式图片需要检查 rels 中的TargetMode属性如果是External则说明图片在外部路径无法从 docx 中导出。4.2 嵌入的 Excel 导出后变成 .bin双击打不开现象导出的 OLE 对象文件扩展名是 .bin手动改成 .xlsx 后 Excel 提示文件损坏。原因OLE 对象在 docx 中存储的是复合文档格式Compound File Binary Format而不是直接的 xlsx。xlsx 本身是 ZIP 格式但 OLE 包装了一层复合文档结构。直接改扩展名并不能改变文件内部格式。解决用olefile库读取复合文档提取其中的Package流或Workbook流。对于 ProgID 为Excel.Sheet.12的对象实际数据在\x01Ole10Native流中需要按 OLE 原生格式解析。如果不想引入额外依赖可以用libreoffice --headless --convert-to xlsx做格式转换但需要系统安装 LibreOffice。4.3 导出目录里图片数量对不上少了十几张现象文档里明明有 30 张图导出目录只有 18 张。原因图片可能分布在多个 rels 文件中。除了word/_rels/document.xml.rels页眉页脚有独立的 rels 文件如word/_rels/header1.xml.rels脚注尾注也有自己的 rels。只解析主文档的 rels 会漏掉这些位置的图片。解决遍历word/_rels/下所有.rels文件合并所有图片引用。同时检查word/media/目录下的实际文件列表以文件系统为准做交叉验证。如果word/media/下有文件但 rels 中没有引用说明是孤立资源也应该导出。4.4 中文文件名导出后变成乱码现象嵌入的附件原始名称是中文导出后文件名变成%E4%B8%AD%E6%96%87这种 URL 编码。原因OLE 对象的显示名称在document.xml中可能以 URL 编码形式存储特别是当文档在不同语言版本的 Word 中编辑过时。直接读取w:name或o:OLEObject的ShapeID关联文本会得到编码后的字符串。解决用urllib.parse.unquote()做解码。如果解码后仍然是乱码说明原始编码不是 UTF-8可能是 GBK 或 Latin-1需要尝试多种编码。更稳妥的做法是放弃从 XML 提取文件名改用 OLE 复合文档中的\x05SummaryInformation流读取原始文件名。4.5 大文档导出时内存爆了现象处理 500 页以上的文档时Python 进程内存占用超过 2GB最终被系统杀掉。原因z.read()会把整个文件读入内存如果文档中有几百 MB 的嵌入视频或大型 Excel内存会迅速耗尽。另外lxml.etree.parse()也会把整个 XML 树加载到内存。解决对于大文件用z.open()返回文件对象配合shutil.copyfileobj()做流式写入。XML 解析改用iterparse做增量解析只保留当前需要的元素。如果文档中有视频等大文件考虑先过滤掉超过阈值如 50MB的资源单独处理。5. 进阶把导出逻辑做成可配置的 CLI 工具5.1 用 argparse 暴露关键参数把前面的函数封装成一个命令行工具方便批量处理多个文档。import argparse import os import sys def main(): parser argparse.ArgumentParser( description导出 Word 文档中的图片和嵌入文件 ) parser.add_argument(docx, help输入的 .docx 文件路径) parser.add_argument(-o, --output, defaultoutput, help输出目录默认: output) parser.add_argument(--images-only, actionstore_true, help只导出图片跳过嵌入文件) parser.add_argument(--embeds-only, actionstore_true, help只导出嵌入文件跳过图片) parser.add_argument(--min-size, typeint, default0, help最小文件大小字节小于此值的文件跳过) parser.add_argument(--dry-run, actionstore_true, help只打印将要导出的文件不实际写入) args parser.parse_args() if not os.path.isfile(args.docx): print(f错误: 找不到文件 {args.docx}, filesys.stderr) sys.exit(1) img_dir os.path.join(args.output, images) emb_dir os.path.join(args.output, embeddings) if not args.embeds_only: export_images(args.docx, img_dir, min_sizeargs.min_size, dry_runargs.dry_run) if not args.images_only: export_embeddings(args.docx, emb_dir, min_sizeargs.min_size, dry_runargs.dry_run) if __name__ __main__: main()参数说明--min-size用于过滤掉缩略图或图标等小文件默认 0 表示不过滤。--dry-run在批量处理前做预检很有用可以先看清楚会导出哪些文件再决定是否执行。--images-only和--embeds-only互斥同时指定时以先判断的为准。5.2 导出结果校验用文件头魔数做二次确认导出完成后跑一遍校验脚本确保每个文件都能被正确识别。MAGIC_NUMBERS { b\x89PNG: png, b\xff\xd8\xff: jpeg, bGIF8: gif, bBM: bmp, bPK\x03\x04: zip, b%PDF: pdf, b\xd0\xcf\x11\xe0: ole, } def verify_export(output_dir): 校验导出目录中的文件类型是否与扩展名匹配 issues [] for root, _, files in os.walk(output_dir): for fname in files: fpath os.path.join(root, fname) with open(fpath, rb) as f: header f.read(8) detected None for magic, ftype in MAGIC_NUMBERS.items(): if header.startswith(magic): detected ftype break ext os.path.splitext(fname)[1].lower().lstrip(.) # 扩展名与检测类型不一致时记录 if detected and ext not in (detected, jpg if detected jpeg else detected): issues.append(f{fpath}: 扩展名 .{ext} 但检测为 {detected}) return issues这个校验脚本能发现大部分导出问题。比如一个文件扩展名是 .png 但魔数是PK\x03\x04说明它实际是个 ZIP 文件可能是 xlsx 被错误命名。一个文件扩展名是 .xlsx 但魔数是\xd0\xcf\x11\xe0说明它是旧版 OLE 格式需要转换。5.3 批量处理多个文档时的目录组织当需要处理一个目录下的所有 .docx 文件时输出目录结构建议按源文件名分目录避免不同文档的同名图片互相覆盖。import glob def batch_export(input_dir, output_root): 批量导出目录下所有 docx 文件 docx_files glob.glob(os.path.join(input_dir, *.docx)) for docx_path in docx_files: base_name os.path.splitext(os.path.basename(docx_path))[0] out_dir os.path.join(output_root, base_name) print(f\n处理: {docx_path} - {out_dir}) export_images(docx_path, os.path.join(out_dir, images)) export_embeddings(docx_path, os.path.join(out_dir, embeddings))目录结构示例output/文档A/images/、output/文档A/embeddings/、output/文档B/images/。这样即使两个文档都有image1.png也不会冲突。如果文档数量很多可以在输出目录下加一个manifest.json记录每个文档的导出统计方便后续核对。5.4 一个容易忽略的细节图片的 DPI 和色彩空间导出的图片如果用于印刷或进一步处理需要注意 DPI 和色彩空间信息。Word 文档中的图片可能嵌入了 EXIF 信息但zipfile直接读取的二进制数据是完整的EXIF 会保留。如果图片是 CMYK 色彩空间导出后用 RGB 工具打开会偏色需要用Pillow做色彩空间转换。from PIL import Image import io def convert_cmyk_to_rgb(image_data): 将 CMYK 图片转换为 RGB img Image.open(io.BytesIO(image_data)) if img.mode CMYK: img img.convert(RGB) buf io.BytesIO() img.save(buf, formatPNG) return buf.getvalue() return image_data这个转换只在需要统一色彩空间时使用。如果只是归档保留原始数据更安全。参数说明Image.open()接受文件对象io.BytesIO把二进制数据包装成文件对象。img.mode返回色彩模式常见的有RGB、CMYK、RGBA、L灰度。我自己的习惯是导出脚本先跑--dry-run看清单确认没有异常大文件或异常类型后再实际执行。导出完成后一定跑一遍verify_export把扩展名和魔数不匹配的文件单独拎出来人工确认。这套流程跑了两年多处理过上千份文档唯一一次翻车是遇到一个嵌入了 200MB 视频的文档内存直接爆了后来加了--min-size和流式写入才解决。希望帮到你。本文还有配套的精品资源点击获取