示例面向第三方
XMind==1.2.0生成的传统 XML 格式。脚本读取 Excel 层级列,检查空值与输出文件结构;ZIP/XML 检查不能代替各版本 XMind 客户端的兼容性验证。
用 Python 将 Excel 层级表转换为 XMind
Excel 每一行表示一条从根向叶子的路径,每一列表示一个层级。相同父节点下的同名子节点只生成一次;不同父节点下的同名节点仍然分别保留。适合把业务指标、分类目录等表格转成可编辑的思维导图。
| L1 | L2 | 驱动杠杆 | 业务域 | 管理重点 | 分析主题 | 指标标准名称 |
|---|---|---|---|---|---|---|
| Level1_A | Level2_A1 | Lever_A1 | Business_A1 | Management_A1 | Analysis_A1 | Standard_A1 |
| Level1_A | Level2_A1 | Lever_A1 | Business_A1 | Management_A1 | Analysis_A1 | Standard_A2 |
| Level1_B | Level2_B1 | Lever_B1 | Business_B1 | Management_B1 | Analysis_B1 | Standard_B1 |

一、安装和输入约定
python -m pip install pandas openpyxl XMind==1.2.0
这里的 PyPI 包是第三方项目 zhuifengshen/xmind,并非当前 XMind 桌面软件的官方 Python API。1.2.0 发布于 2019 年;固定该版本是为了对应本文使用的接口,不能由此推断它支持所有新版格式。PyPI 项目页面
把 Excel 保存为 input.xlsx,首行为列名。脚本显式指定从左到右的层级列,不会把额外的备注、编号列自动变成主题。末尾空层级表示路径结束;中间空层级后还有内容时拒绝转换,因为静默跳过会改变层级关系。全空行忽略。合并单元格需要先在 Excel 中填充为完整路径,本文不会猜测空白是继承上一行还是结束。
read_excel(..., keep_default_na=False) 避免把字符串 NA、NULL 当作默认缺失值;转换函数也处理真正的 None 和 NaN。有前导零的编码应在 Excel 中存成文本,数字单元格的显示格式不会自动变成字符串值。pandas Excel 读取说明
二、完整脚本
保存为 excel_to_xmind.py。默认拒绝覆盖已有文件,输出失败会抛出异常;不要把失败提示捕获后仍然显示“完成”。
from pathlib import Path
import shutil
import tempfile
import xml.etree.ElementTree as ET
import zipfile
import pandas as pd
import xmind
LEVELS = ['L1', 'L2', '驱动杠杆', '业务域', '管理重点', '分析主题', '指标标准名称']
MANIFEST = 'META-INF/manifest.xml'
NS = 'urn:xmind:xmap:xmlns:manifest:1.0'
def normalize(value):
if value is None or pd.isna(value):
return ''
return str(value).strip()
def rebuild_archive(source, target):
"""重建 ZIP,移除旧 manifest,生成一个且仅一个清单条目。"""
ET.register_namespace('', NS)
manifest = ET.Element(f'{{{NS}}}manifest')
with zipfile.ZipFile(source, 'r') as src:
names = src.namelist()
if len(names) != len(set(names)):
raise ValueError('输入 XMind 压缩包包含重复条目')
if 'content.xml' not in names or src.testzip() is not None:
raise ValueError('输入 XMind 压缩包缺少正文或校验失败')
ET.fromstring(src.read('content.xml'))
with zipfile.ZipFile(target, 'w', compression=zipfile.ZIP_DEFLATED) as dst:
for info in src.infolist():
if info.filename == MANIFEST:
continue
dst.writestr(info, src.read(info.filename))
if not info.is_dir() and not info.filename.startswith('META-INF/'):
ET.SubElement(manifest, f'{{{NS}}}file-entry', {'full-path': info.filename})
dst.writestr(MANIFEST, ET.tostring(manifest, encoding='utf-8', xml_declaration=True))
with zipfile.ZipFile(target) as check:
if check.namelist().count(MANIFEST) != 1 or check.testzip() is not None:
raise ValueError('输出 XMind 压缩包校验失败')
ET.fromstring(check.read(MANIFEST))
def dataframe_to_xmind(df, output, levels=LEVELS, root_title='指标体系'):
output = Path(output)
if output.suffix.lower() != '.xmind':
raise ValueError('输出文件必须使用 .xmind 扩展名')
if not levels or len(levels) != len(set(levels)):
raise ValueError('层级列必须非空且不能重复')
if df.columns.has_duplicates:
raise ValueError('输入表包含重复列名')
missing = [name for name in levels if name not in df.columns]
if missing:
raise ValueError(f'缺少层级列:{missing}')
if output.exists():
raise FileExistsError(f'输出文件已存在:{output}')
if not output.parent.is_dir():
raise FileNotFoundError(f'输出目录不存在:{output.parent}')
paths = []
for row_number, values in enumerate(df.loc[:, levels].itertuples(index=False, name=None), start=2):
titles = [normalize(value) for value in values]
if not any(titles):
continue
end = next((i for i, title in enumerate(titles) if not title), len(titles))
if any(titles[end:]):
raise ValueError(f'Excel 第 {row_number} 行有中间缺失层级')
paths.append(titles[:end])
if not paths:
raise ValueError('没有可转换的非空路径')
# 在临时目录创建新工作簿,绝不加载目标文件后继续追加。
with tempfile.TemporaryDirectory() as temp_dir:
source = Path(temp_dir) / 'new.xmind'
fixed = Path(temp_dir) / 'fixed.xmind'
workbook = xmind.load(str(source))
sheet = workbook.getPrimarySheet()
sheet.setTitle(root_title)
root = sheet.getRootTopic()
root.setTitle(root_title)
nodes = {(): root}
for titles in paths:
for depth, title in enumerate(titles, start=1):
key = tuple(titles[:depth])
if key not in nodes:
child = nodes[key[:-1]].addSubTopic()
child.setTitle(title)
nodes[key] = child
xmind.save(workbook, str(source))
rebuild_archive(source, fixed)
# 独占创建处理“检查存在后,其他进程刚好创建文件”的竞争。
with fixed.open('rb') as src, output.open('xb') as dst:
try:
shutil.copyfileobj(src, dst)
except BaseException:
dst.close()
output.unlink(missing_ok=True)
raise
return output
if __name__ == '__main__':
frame = pd.read_excel('input.xlsx', sheet_name=0, engine='openpyxl',
dtype=object, keep_default_na=False)
result = dataframe_to_xmind(frame, 'output.xmind')
print(f'已生成:{result}')
三、为什么这样构建
节点缓存的键是完整路径,例如 ('Level1_A', 'Level2_A1'),因此能合并同一路径的公共祖先,也不会把不同父节点下的同名节点混为一谈。第一次出现的顺序就是主题顺序。strip() 会合并只有首尾空格不同的标题;若空格具有业务含义,需要调整这个规则。
传统 .xmind 文件是 ZIP 容器。若在追加模式下写入 META-INF/manifest.xml,已有清单时会产生两个同名条目,不同读取器可能选择不同条目。因此脚本重建压缩包,并检查条目唯一性、XML 和 CRC。库保存器负责 content.xml 等正文文件,清单处理用于适配这个版本的库。库保存器源码、Python ZIP 文档
这不是通用“修复所有 XMind 文件”的工具。生成后还要在实际使用的 XMind 客户端打开,检查层级、中文标题和保存后重新打开的结果。没有完成这一步时,只能确认文件结构检查通过。
评论区