Python处理word中的visio图像

起因

来自于工作中一个任务,有一批Word,里面记录了一些卡片式的文件,卡片里面有一些流程图 — 用Visio画的。

需求是需要将所有的Visio提取出来,然后重命名为卡片里面两个位置标识组成的新名字。

冻手冻手

这种重复性的活,手动做,真的很恶心,而且浪费时间,做的过程中心里全是怨念hhh。我学Python为的不就是不干这种活?

所以最开始就没想过自己动手,Word罢了,解析解析看看。

首先介绍下文件的情况

  • 需要处理的文件格式是docx,这肯定是一个好消息,因为docx中的资源肯定是有办法可以搞到的。

    docx 文件是一个基于 Office Open XML (OOXML) 标准的文件格式,它由多个 XML 文件和其他资源(如图像、嵌入对象等)组成,这些都打包在一个 ZIP 容器中。

  • 手动操作的话是双击visio图像,然后会自动调用电脑上的visio,此时再去做另存和重命名

需求向通义提了以后,给出的方式有VBA也有Python,起初是建议使用win32com和python-docx去对docx文件进行处理,然后找到visio图像对象,另存为本地文件,大概思路是这样。

实际上呢,python-docx这个库很不巧,在这里是不可行的,并不支持对word中内联的visio图像进行一个读取。

win32com模拟操作 — 仅介绍思路与给出代码

给出的示例代码基本都是使用win32com去调用word,然后捕获到visio drawing对象 — visio drawingvisio图像在Word中的对象名

visio drawing对象的存在位置主要有两种:

  • inline_shapes
  • shapes

前者是内联形状,后者是普通形状。

visio drawing对象在Word中的存在形式是OLEObject(对象链接和嵌入对象),visio在其中存在时,不仅仅是一个图片,其中包含了visio drawing的一些特征,因此双击以后,其实就可以对流程/逻辑图进行绘制。

模拟操作的大致思路是这样的

  1. win32com调用word打开docx文档(用WindoCOM接口来控制Word),获取doc对象

  2. 遍历doc对象中的shapesinline_shapes对象

  3. 判断shape是否是Visio Drawing对象

    1. 如果shapeOLEFormat属性,那么可以初步判断它是一个OLEObject

      1
      2
      if hasattr(shape, 'OLEFormat'):
      pass
    2. 判断shape.OLEFormatProgID属性 — 这个不同版本的Visio绘制的有所区别,可以输出看看,然后再选

      1
      2
      ole_format = shape.OLEFormat
      ole_format.ProgID == 'Visio.Drawing.15': # 特定版本的Visio Drawing
    3. 激活并获取Visio绘图 — 此时会启动visio程序,然后在那个visio程序中会打开绘图,绘制对应的Visio Drawing对象

      1
      2
      ole_format.Activate()
      visio_drawing = ole_format.Object
    4. 保存Visio绘图为新的Visio文件 — 调用visio应用实例的保存方法

      1
      visio_drawing.SaveAs(file_name)
    5. 关闭上面打开的绘图,这一步我遇到了很多问题,我不确定是否不同环境会有所区别,我尝试了各种打开方案!我可以确定ole_format是有Close方法的,因为我使用了dir进行查看。但是当执行时却有提示没有这个方法,emm。然后最后我也不知道动了哪里,使用这个方法又可以关闭视图了。

      1
      visio_drawing.Close()
    6. 最后一定一定要注意 打开的窗口保存以后必须关闭,否则电脑的内存会爆!

    7. 最后退出应用程序即可

并不推荐使用这种方式,因为不确定因素太多了,实际上就是用Python模拟自动化。

最后调试很久得到一个可行的方案以后,不巧的是,执行速度很慢,而且顺序错了。重命名怎么办?我最初的想法是按照顺序批量将名字导出来,简单拼接,然后再按照图片导出的顺序,批量重命名 — 可行!

但是!顺序错了,over,不只是执行慢,而且不可用。为什么顺序会错?之前提过

visio drawing对象的存在位置主要有两种:

  • inline_shapes
  • shapes

是的,两种存在形式是混在一起的,泪目了吗?

下面是最后的程序,至少,可以提取出所有的visio图像

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
import os
import win32com.client as win32
import traceback


def save_visio_drawings(doc_path, output_folder):
try:
# 启动Word应用程序
word = win32.Dispatch("Word.Application")
doc = word.Documents.Open(doc_path)

# 确保输出文件夹存在
if not os.path.exists(output_folder):
os.makedirs(output_folder)

# 启动Visio应用程序
visio = win32.DispatchEx("Visio.Application") # 尝试使用DispatchEx
visio.Visible = False # 不显示Visio窗口

# 遍历文档中的样式
for shape_index, shape in enumerate(doc.Shapes):
if hasattr(shape, 'OLEFormat'):
ole_format = shape.OLEFormat
print(ole_format.ProgID)

print(f"Found Visio drawing {shape_index}...")

try:
# 激活并获取Visio绘图
ole_format.Activate()
visio_drawing = ole_format.Object

# 保存Visio绘图为新的Visio文件
file_name = os.path.join(output_folder, f'shape_{shape_index}.vsdx')
visio_drawing.SaveAs(file_name)
print(f"Saved Visio drawing to {file_name}")
except Exception as e:
print(f'Error processing Visio drawing {shape_index}: {e}')
traceback.print_exc()

# 遍历文档中的所有内联形状
for shape_index, shape in enumerate(doc.InlineShapes):
# 这里代码和上面一样,最好是函数封一下
pass

# 关闭Visio应用程序
visio.Quit()
except Exception as e:
print(f'Error processing document: {e}')
traceback.print_exc()
finally:
# 关闭文档和Word应用程序
if 'doc' in locals():
doc.Close(False) # 不保存更改
if 'word' in locals():
word.Quit()


doc_path = r'C:\Users\XYT\Desktop\test.docx'
output_folder = r'C:\Users\XYT\Desktop\s'
save_visio_drawings(doc_path, output_folder)

冻手啊!

前文提过,既然是docx格式的word,那包能提取到东西的。

`docx文件是一个基于 Office Open XML (OOXML) 标准的文件格式,它由多个 XML 文件和其他资源(如图像、嵌入对象等)组成,这些都打包在一个 ZIP 容器中。

将docx文件后缀改为zip,然后解压,找找我要的东西在哪!

1f4a58f8de178815da57a98159e22cce.png

32319e62c68f395af15ee68790fb425a.png

c35032b419534830c3affcf3013ae971.png

1415aefd9ae887e520e8eb7b4084e821.png

如上图所示,其实在图三(word/embeddings)中,已经有了我们要的东西vsdx文件!!!但是这只是运气比较好,图四才是大多数文件的常态。

embeddings文件夹中存在的文件都是word中嵌入的对象,visio嵌入的图像全在里面,要做的就是将这些图像转换成visio可以打开的格式,一般就2种

  • vsd — 旧版本
  • `vsdx— 新版本

这里我大量的尝试修改后缀为visio支持的各种格式来猜测图像,如果可以打开,直接改后缀就行 — 结果有些可以有些不行

所以直接改后缀的方式无疑要pass,毕竟我不知道哪些是我要的文件

那就只能老实处理 oleObject 这个文件了

天无绝人之路 — olefile

所幸,Python有三方库可以处理这种文件 — olefile

olefile是一个 Python 软件包,用于解析、读写 微软 OLE2 文件 (也称为结构化存储、复合文件二进制格式或复合文档文件格式),如 Microsoft Office 97-2003 文档、MS Office 2007+ 文件中的 vbaProject.bin、Image Composer 和 FlashPix 文件、Outlook 信息、StickyNotes、几种 Microscopy 文件格式、McAfee 杀毒软件隔离文件等。

后续思路大概就是这样的

  1. 将docx作为zip压缩包解压 — 使用zipfile

  2. 获取解压后的文件夹中embeddings文件夹下的文件列表

    要注意顺序,因为最后需要按照顺序导出,按照顺序重命名,顺序规则本身就是约束

  3. 遍历文件列表,判断文件是不是oleObject文件,是的话则进行后续的处理

    1
    2
    if olefile.isOleFile(file_path):
    pass

    isOleFile()olefile 模块中的一个函数,用于检测给定路径 (file_path) 的文件是否为 OLE(对象链接和嵌入)格式的文件。

  4. 读取 OLE 文件,然后在读取 OLE 文件内所有流 (stream) 和存储 (storage) 的名称 — 这就是 OLE 文件的数据了,需要通过这些数据判断oleObject是不是visio drawing对象对应的文件

    1
    2
       

  5. 然后需要判断 OLE 文件是不是就是对应的Visio文件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    if olefile.isOleFile(file_path):
    ole = olefile.OleFileIO(file_path)
    streams = ole.listdir()
    for stream in streams:
    if stream[-1].lower().endswith(('.vsd', '.vsdx')):
    pass
    if stream[-1].lower() == 'visiodocument':
    pass
    if stream[-1].lower()=='package':
    pass
    else:
    print(stream[-1].lower())
  6. 对不同分支进行不同的处理即可

1
2
3
4
5
6
7
8
9
10
11
12
if olefile.isOleFile(file_path):
ole = olefile.OleFileIO(file_path)
streams = ole.listdir()
for stream in streams:
if stream[-1].lower().endswith(('.vsd', '.vsdx')):
pass
if stream[-1].lower() == 'visiodocument':
pass
if stream[-1].lower()=='package':
pass
else:
print(stream[-1].lower())

从这段代码中,可以看到匹配visio的各种条件,这些都是逐步摸索出来的,比如上面截图中直接以.vsdx形式存在的文件,匹配第一个条件

至于第二第三个条件则分别对应两种格式 — 在embeddings文件夹中的存在形式都一样,都是oleObject.bin

  • vsd
  • vsdx

image-20241205003033645.png

其中vsd对应的oleObject.bin文件直接修改后缀其实就可以用Visio打开,但是vsdx对应的oleObject.bin不行

vsdx其实和docx一样,tm也是zip,所以处理的方式就是先将其做为zip解压,然后将解压以后的文件作为vsdx文件

大致思路就像上面说的一样,下面是最终的实现代码 — 不是很没美观,因为只想把活做完qwq

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
import os
import shutil
import win32com.client as win32
from zipfile import ZipFile
import logging
import tempfile
import olefile
import re


# 设置日志记录
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')


def extract_first_number(filename):
mat = re.search(r'\d+', filename)
return int(mat.group()) if mat else -1


def extract_and_convert_ole_objects(docx_path, output_folder, name_path):
try:
with open(name_path, 'r+') as fp:
names = fp.readlines()
names = [name.strip().replace('/', '').replace('\\', '') for name in names]
except Exception as e:
logging.error(f"Failed to read {name_path}: {e}")
try:
with tempfile.TemporaryDirectory() as temp_dir:
with ZipFile(docx_path, 'r') as zip_ref:
zip_ref.extractall(temp_dir)

embeddings_path = os.path.join(temp_dir, 'word/embeddings')
if os.path.exists(embeddings_path):

files = os.listdir(embeddings_path)
sort_files = sorted(files, key=extract_first_number)

for index, filename in enumerate(sort_files):
file_path = os.path.join(embeddings_path, filename)
if filename.lower().endswith('.bin'):
output_file_path = os.path.join(output_folder, f"{names[index]}.vsd")
logging.info(f"Extracted and converted {filename} to {output_file_path}")
if olefile.isOleFile(file_path):
ole = olefile.OleFileIO(file_path)
streams = ole.listdir()
for stream in streams:
if stream[-1].lower().endswith(('.vsd', '.vsdx')):
data = ole.openstream(stream).read()
output_file_path = os.path.join(output_folder,
f"{names[index]}{os.path.splitext(stream[-1])[1]}")
with open(output_file_path, 'wb') as f:
f.write(data)
logging.info(f"Extracted and converted {filename} to {output_file_path}")
if stream[-1].lower() == 'visiodocument':
data = ole.openstream(stream).read()
output_file_path = os.path.join(output_folder, f"{names[index]}.vsd")
with open(output_file_path, 'wb') as f:
f.write(data)
logging.info(f"Extracted and converted {filename} to {output_file_path}")
if stream[-1].lower() == 'package':
try:
# Read the 'package' stream data
data = ole.openstream(stream).read()

# Write the data to a temporary ZIP file
with tempfile.NamedTemporaryFile(delete=False, suffix='.zip') as tmp_zip:
tmp_zip.write(data)
tmp_zip_path = tmp_zip.name

# Extract the temporary ZIP file to a temporary directory
with tempfile.TemporaryDirectory() as extract_dir:
with ZipFile(tmp_zip_path, 'r') as zip_ref:
zip_ref.extractall(extract_dir)

# Create a new ZIP archive from the extracted directory
# output_file_path = os.path.join(output_folder, f"{names[index]}.vsd")
vsdx_file_name = os.path.join(output_folder, f"{names[index]}.vsdx")
shutil.make_archive(vsdx_file_name.rstrip('.vsdx'), 'zip', extract_dir)
shutil.copy(f"{vsdx_file_name[:-5]}.zip", vsdx_file_name)

# Log the success message
logging.info(f"Extracted and converted {filename} to {vsdx_file_name}")

# Clean up the temporary ZIP file
os.remove(tmp_zip_path)
except Exception as e:
logging.error(f"Failed to process 'package' stream in {filename}: {e}")
ole.close()
else:
logging.warning(f"{filename} is not an OLE file.")
else:
logging.warning(f"No embeddings found in {docx_path}.")
except Exception as e:
logging.error(f"Failed to extract and convert objects from {docx_path}: {e}")


def doc_to_docx(doc_path):
word = win32.Dispatch("Word.Application")
word.Visible = False # 不显示Word应用程序窗口
try:
doc = word.Documents.Open(doc_path)
temp_fd, temp_path = tempfile.mkstemp(suffix='.docx')
os.close(temp_fd)
doc.SaveAs2(temp_path, FileFormat=16) # 16 表示 .docx 文件格式
doc.Close()
return temp_path
except Exception as e:
logging.error(f"Failed to convert {doc_path}: {e}")
raise
finally:
word.Quit()


def process_files(input_folder, output_folder):
if not os.path.exists(output_folder):
os.makedirs(output_folder)

for root, dirs, files in os.walk(input_folder):
for file in files:
base_name, ext = os.path.splitext(file)
if ext.lower() in ('.doc', '.docx'):
input_file_path = os.path.join(root, file)
output_subfolder = os.path.join(output_folder, base_name)

new_names_path = os.path.join(root, base_name + '.txt')

if not os.path.exists(output_subfolder):
os.makedirs(output_subfolder)

logging.info(f"Processing file: {file}")
if ext.lower() == '.doc':
try:
temp_docx_path = doc_to_docx(input_file_path)
extract_and_convert_ole_objects(temp_docx_path, output_subfolder, new_names_path)
finally:
if os.path.exists(temp_docx_path):
os.remove(temp_docx_path)
elif ext.lower() == '.docx':
extract_and_convert_ole_objects(input_file_path, output_subfolder, new_names_path)

if __name__ == "__main__":
input_folder = 'input'
output_folder = 'output'

process_files(input_folder, output_folder)

程序嵌套有点多,处理OLE文件语句可以简单打包其实,懒~

总结

大致就是这样了,最终导出的成果还算满意,基本所有visio都导出来了,而且顺序也ok

-------------已经到底啦!-------------

欢迎关注我的其它发布渠道