解决Python生成XMind文件无法打开

转载请注明出处❤️

作者:测试蔡坨坨

原文链接:caituotuo.top/f910170a.html


1 问题背景

你好,我是测试蔡坨坨。

最近在尝试使用AI生成测试用例,为了让用例可视化效果最佳,想了一个简单的实现思路:先让AI生成JSON格式的测试用例,再使用Python脚本将JSON转成XMind文件,进一步可以将其包装成MCP工具以供大模型使用,然而在实践过程中发现……

生成的.xmind文件在XMind 2020+版本里死活打不开,报错“not a valid XMind File”:

简单分析一下:

  • 生成的文件大小正常,排除代码方面的问题
  • 使用老版本XMind 8可以打开,但新版本就不行
  • XMind 8 和 XMind 2020+ 的区别是什么?

2 排查过程

2.1 XMind 文件结构

顺着上面的分析,在 XMind 8 版本中可以打开,而在 XMind 2020+ 版本中无法打开,基本可以确认是版本兼容性问题,也就是说我们使用Python的xmind库生成的XMind文件是 XMind 8 版本,所以只要我们知道两个版本之间的差异,并通过某种方式将 XMind 8 版本的文件转成兼容高版本的不就OK了。

那么问题来了,某种什么方式呢?

比如:先用 XMind 8 打开用 Python 生成的 XMind 文件,再另存为,这时就能兼容高版本了。

显然这种做法有点笨,不仅不能自动化,还得装两个版本的XMind,毕竟大部分人用的都是 XMind 2020+ 版本。

所以有没有更智能的方式呢?

有的兄弟!

回到前面的问题,要想实现两个版本的 XMind 文件转换,我们得先知道它两有什么区别。

众所周知,XMind文件其实就是一个zip压缩包,里面包含了XML文件和其他资源。

试着解压看看,使用下面这两条命令:

1
cp xmind测试用例_1754818615.xmind test_cases.zip
1
unzip test_cases.zip -d extracted/

XMind 8:

XMind 2020+:

解压后发现里面有这些文件:

  • content.xml:主要内容
  • styles.xml:样式定义
  • comments.xml:评论
  • //META-INF/manifest.xml:文件清单,差别就在这个文件,XMind 8 没有,高版本有

2.2 解决思路

既然知道了问题所在,解决起来就简单了,在生成XMind文件之后,再添加//META-INF/manifest.xml文件,这样就能解决版本兼容问题了。

3 代码实现

3.1 创建manifest.xml文件

1
2
3
4
5
6
7
8
9
10
11
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<manifest xmlns="urn:xmind:xmap:xmlns:manifest:1.0">
<file-entry full-path="comments.xml" media-type=""/>
<file-entry full-path="content.xml" media-type="text/xml"/>
<file-entry full-path="markers/" media-type=""/>
<file-entry full-path="markers/markerSheet.xml" media-type=""/>
<file-entry full-path="META-INF/" media-type=""/>
<file-entry full-path="META-INF/manifest.xml" media-type="text/xml"/>
<file-entry full-path="meta.xml" media-type="text/xml"/>
<file-entry full-path="styles.xml" media-type=""/>
</manifest>

3.2 代码

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
def fix_xmind_compatibility(self, xmind_path: str, manifest_path: str = None) -> bool:
"""
修复 XMind 版本兼容性问题

**场景**: 生成的xmind文件只能在 XMind 8 中打开 XMind 2020+ 中无法打开
**解决方案**: 通过添加 manifest.xml 文件来修复兼容性问题

流程:
1. 将 .xmind 文件重命名为 .zip
2. 解压 .zip 文件
3. 添加 META-INF/manifest.xml 文件
4. 重新压缩为 .zip
5. 重命名回 .xmind

Args:
xmind_path: XMind 文件路径
manifest_path: manifest.xml 文件路径,如果为 None 则使用默认路径

Returns:
bool: 处理是否成功
"""
if not os.path.exists(xmind_path):
self.log.error(f"XMind 文件不存在: {xmind_path}")
return False

# 如果没有提供 manifest.xml 路径,使用默认路径
if manifest_path is None:
manifest_path = os.path.join(os.path.dirname(__file__), "manifest.xml")

if not os.path.exists(manifest_path):
self.log.error(f"manifest.xml 文件不存在: {manifest_path}")
self.log.info("请确保 manifest.xml 文件存在,或提供正确的文件路径")
return False

# 获取文件路径信息
folder = os.path.dirname(xmind_path)
filename = os.path.basename(xmind_path)
name_without_ext = os.path.splitext(filename)[0]

# 定义临时文件和目录路径
temp_zip_path = os.path.join(folder, f"{name_without_ext}.zip")
temp_extract_dir = os.path.join(folder, f"{name_without_ext}_temp")

# 保存当前工作目录
original_cwd = os.getcwd()

try:
self.log.info(f"开始处理 XMind 文件: {xmind_path}")

# 步骤 1: 重命名 .xmind 为 .zip
if os.path.exists(temp_zip_path):
os.remove(temp_zip_path)
shutil.copy2(xmind_path, temp_zip_path)
self.log.debug(f"创建临时 ZIP 文件: {temp_zip_path}")

# 步骤 2: 解压 ZIP 文件
if os.path.exists(temp_extract_dir):
shutil.rmtree(temp_extract_dir)

if not self.extract(temp_extract_dir, temp_zip_path):
self.log.error("解压 XMind 文件失败")
return False

# 步骤 3: 替换 manifest.xml
meta_inf_dir = os.path.join(temp_extract_dir, "META-INF")
os.makedirs(meta_inf_dir, exist_ok=True)

target_manifest_path = os.path.join(meta_inf_dir, "manifest.xml")
shutil.copy2(manifest_path, target_manifest_path)
self.log.debug(f"替换 manifest.xml: {manifest_path} -> {target_manifest_path}")

# 步骤 4: 重新压缩
os.remove(temp_zip_path) # 删除旧的 ZIP 文件

# 使用 shutil.make_archive 重新压缩
archive_path = shutil.make_archive(
os.path.join(folder, name_without_ext),
'zip',
temp_extract_dir
)
self.log.debug(f"重新压缩完成: {archive_path}")

# 步骤 5: 替换原始 XMind 文件
# 重命名压缩文件为 .xmind
shutil.move(archive_path, xmind_path)
self.log.info(f"XMind 文件处理完成: {xmind_path}")

return True

except Exception as e:
self.log.error(f"处理 XMind 文件时发生错误: {e}")
return False

finally:
# 清理临时文件和目录
try:
if os.path.exists(temp_zip_path):
os.remove(temp_zip_path)
if os.path.exists(temp_extract_dir):
shutil.rmtree(temp_extract_dir)
self.log.debug("临时文件清理完成")
except Exception as e:
self.log.warning(f"清理临时文件时发生错误: {e}")

# 恢复原始工作目录
os.chdir(original_cwd)
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
def extract(self, extract_to_path: str, archive_path: str, mode: str = "zip") -> bool:
"""
解压缩文件并处理中文文件名乱码问题

Args:
extract_to_path: 解压目标目录
archive_path: 压缩文件路径
mode: 压缩格式 ("zip" 或 "rar")

Returns:
bool: 解压是否成功
"""
if not os.path.exists(archive_path):
self.log.error(f"压缩文件不存在: {archive_path}")
return False

# 确保解压目录存在
os.makedirs(extract_to_path, exist_ok=True)
self.log.info(f"开始解压 {mode} 文件: {archive_path} -> {extract_to_path}")

try:
# 使用 with 语句确保文件正确关闭
if mode == 'zip':
archive_file = zipfile.ZipFile(archive_path, "r")
elif mode == 'rar':
archive_file = rarfile.RarFile(archive_path, "r")
else:
self.log.error(f"不支持的压缩格式: {mode}")
return False

with archive_file:
extracted_count = 0
for file_info in archive_file.infolist():
original_filename = file_info.filename

# 尝试多种编码方式解决中文乱码问题
decoded_filename = self._decode_filename(original_filename)

# 构建完整的文件路径
full_path = os.path.join(extract_to_path, decoded_filename)

# 处理目录
if decoded_filename.endswith("/") or decoded_filename.endswith("\\"):
os.makedirs(full_path, exist_ok=True)
self.log.debug(f"创建目录: {full_path}")
else:
# 处理文件
# 确保父目录存在
parent_dir = os.path.dirname(full_path)
if parent_dir:
os.makedirs(parent_dir, exist_ok=True)

# 写入文件内容
try:
with open(full_path, "wb") as output_file:
output_file.write(archive_file.read(original_filename))
extracted_count += 1
self.log.debug(f"解压文件: {decoded_filename}")
except Exception as e:
self.log.warning(f"解压文件失败 {decoded_filename}: {e}")
continue

self.log.info(f"解压完成,共解压 {extracted_count} 个文件")
return True

except Exception as e:
self.log.error(f"解压过程中发生错误: {e}")
return False
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
def _decode_filename(self, filename: str) -> str:
"""
尝试多种编码方式解码文件名,解决中文乱码问题

Args:
filename: 原始文件名

Returns:
str: 解码后的文件名
"""
# 编码尝试顺序:UTF-8 -> GBK -> 原始文件名
encoding_attempts = [
("utf-8", "UTF-8编码"),
("gbk", "GBK编码"),
("gb2312", "GB2312编码"),
]

for encoding, desc in encoding_attempts:
try:
decoded_name = filename.encode('cp437').decode(encoding)
self.log.info(f'文件名解码成功 ({desc}): {filename} -> {decoded_name}')
return decoded_name
except (UnicodeDecodeError, UnicodeEncodeError):
continue

# 如果所有编码都失败,使用原始文件名
print(f"文件名解码失败,使用原始文件名: {filename}")
return filename