把 .xmind 思维导图批量整理成 Markdown 是常见需求,但很多人会误以为 xmind 是某种私有二进制格式。实际上它就是一个 ZIP 压缩包:改扩展名为 .zip 即可解开,旧版 XMind 的导图结构存放在 content.xml,新版 XMind Zen 则是 content.json,附件图片位于 attachments/ 目录,此外还有 Thumbnails/、Revisions/ 这类与正文无关的条目。基于这个事实,本文用 Node.js 配合 JSZip 与 fast-xml-parser,把 content.xml 解析成对象树,再递归映射为 Markdown 无序列表,同时把导图中引用到的图片落地到 md 同级 images/ 目录,用相对路径引用。
一、技术栈与依赖安装
运行时只需要 Node.js(原文环境为 v20.20.2),不依赖 Python。核心库两个:JSZip 负责在内存里把 .xmind 当 ZIP 读取并取出图片字节;fast-xml-parser 负责把 content.xml 解析为 JavaScript 对象树。文件读写、路径拼接、递归建目录则用 Node 内置的 fs 与 path。安装命令:
- npm install jszip fast-xml-parser
复制代码
二、content.xml 的树形结构与映射规则
导图在数学上是一棵树,XML 通过嵌套标签表达层级:xmap-content 下可有多个 sheet,每个 sheet 下的 topic 是根主题,根主题包含 title、notes、labels、children 等子节点,子主题挂在 children.topics 下,其中 topics 带有 type="attached" 属性,topic 数组层层递归。图片在 xmind 中表现为一个“图片主题”,内部是 xhtml:img 标签:
- <topic id="...">
- <xhtml:img xhtml:src="xap:attachments/767o3sb2dkk7gkj64ckn5ic077.png" .../>
- </topic>
复制代码
注意 xap: 只是 XML 命名空间前缀,并不是真实路径,去掉后 attachments/xxx.png 才是 ZIP 内部图片的实际地址。
映射到 Markdown 的规则为:根主题输出为一级标题,并去掉冗余的 SystemAnalyst- 前缀;一级及以下主题用嵌套无序列表表示,以两空格缩进表达层级;notes 笔记缩进一级后逐行输出为 > 引用块;labels 标签在行尾追加;图片主题输出为  的相对路径形式,纯图片且无标题的节点直接用图片语法作为列表项。图片统一写成 images/文件名,图片文件放在 md 同级目录的 images/ 中,这样 md 移动到哪、只要 images/ 一起移动,引用就一直有效。
三、核心实现
1. 用 JSZip 在内存中读取 XML
把 xmind 当普通 zip 载入,读回 content.xml 字符串,全程不落中间解压文件,干净且高效:
- const JSZip = require('jszip');
- const data = fs.readFileSync(xmindPath);
- const zip = await JSZip.loadAsync(data);
- const xml = await zip.file('content.xml').async('string');
复制代码
2. fast-xml-parser 的关键配置
- const { XMLParser } = require('fast-xml-parser');
- const parser = new XMLParser({
- ignoreAttributes: false,
- attributeNamePrefix: '@_',
- isArray: (name) => ['topic','topics','sheet','label'].includes(name),
- textNodeName: '#text'
- });
复制代码
其中 isArray 是最重要的一项。fast-xml-parser 默认的返回策略是“只有一个子节点时给对象、多个时给数组”,一旦节点数量变化,递归代码就会因为类型不一致而崩溃。强制 topic、topics、sheet、label 永远返回数组后,遍历逻辑才稳定。ignoreAttributes 必须为 false 才能拿到 type="attached" 这类属性,attributeNamePrefix 给属性名加 @_ 前缀便于和子节点区分。解析后的访问路径大致是:
- parsed['xmap-content'].sheet[i].topic[j]
- .children.topics[k].topic[m]
复制代码
3. 递归生成 Markdown
walkTopic 是转换的核心,靠 depth 参数控制缩进(' '.repeat(depth) 生成两空格层级),靠 parentTitle 把父标题传给子节点,供图片主题生成 alt 文本时使用。标题内的换行会折叠为单个空格,避免破坏 Markdown 列表结构:
- function walkTopic(topic, depth, out, isRoot, parentTitle, imagesMap, usedImages) {
- const originalTitle = txt(topic.title).replace(/\s*\n\s*/g, ' ').trim();
- const imgs = findImg(topic);
- let isImageOnly = false;
- let title = originalTitle;
- let hasContent = true;
- if (!title) {
- if (imgs.length) {
- isImageOnly = true;
- title = imgs.map(n => imgMd(n, parentTitle, imagesMap, usedImages)).join(' ');
- } else {
- hasContent = false;
- }
- }
- if (isRoot) {
- if (title.startsWith('SystemAnalyst-')) title = title.slice('SystemAnalyst-'.length);
- if (title) out.push('# ' + title + '\n');
- } else if (hasContent) {
- let line = ' '.repeat(depth) + '- ' + title;
- const labels = getLabels(topic);
- if (labels.length) line += ' `[' + labels.join(', ') + ']`';
- out.push(line);
- }
- const notes = getNotes(topic);
- if (notes) {
- const ind = isRoot ? 0 : (hasContent ? depth + 1 : depth);
- for (const ln of notes.split('\n')) out.push(' '.repeat(ind) + '> ' + ln.trim());
- }
- if (imgs.length && !isImageOnly) {
- const ind = isRoot ? 1 : depth + 1;
- const alt = originalTitle || parentTitle || '图片';
- for (const n of imgs) {
- out.push(' '.repeat(ind) + '- ' + imgMd(n, alt, imagesMap, usedImages));
- }
- }
- const children = topic.children;
- if (children && children.topics) {
- for (const tn of children.topics) {
- const subs = tn.topic || [];
- const childDepth = isRoot ? 0 : (hasContent ? depth + 1 : depth);
- const nextParent = (title && !isImageOnly) ? title : parentTitle;
- for (const c of subs) walkTopic(c, childDepth, out, false, nextParent, imagesMap, usedImages);
- }
- }
- }
复制代码
4. 图片映射与落地
先扫描 zip 建立“文件名 → 内部完整路径”的映射,并排除缩略图与历史版本:
- function buildImageMap(zip) {
- const map = new Map();
- for (const name of Object.keys(zip.files)) {
- const entry = zip.files[name];
- if (entry.dir) continue;
- if (/Thumbnails|Revisions/i.test(name)) continue;
- if (/\.(png|jpe?g|gif|svg|webp|bmp)$/i.test(name)) {
- map.set(name.split('/').pop(), name);
- }
- }
- return map;
- }
复制代码
转换完成后,只把本文件实际引用到的图片写出到 images/:
- fs.mkdirSync(imgDir, { recursive: true });
- for (const n of usedImages) {
- const internal = imagesMap.get(n);
- if (internal) {
- const buf = await zip.file(internal).async('nodebuffer');
- fs.writeFileSync(path.join(imgDir, n), buf);
- }
- }
复制代码
而图片主题转 Markdown 引用的函数里,一旦命中映射就把文件名记入 usedImages 集合,命不中则降级为占位文本:
- function imgMd(name, alt, imagesMap, usedImages) {
- if (imagesMap.has(name)) {
- usedImages.add(name);
- return '';
- }
- return '[图片: ' + name + ']';
- }
复制代码
5. 边界与降级处理
批量转换最怕一个坏节点拖垮整批,脚本做了几处兜底:图片在 zip 中找不到时输出 [图片: 文件名] 占位,不中断任务;无标题且非图片的空节点不输出自身但保留子结构,层级不丢失;xap:attachments/xxx.png 通过取 / 后段得到真实文件名再回 attachments/ 查找,绕开命名空间前缀干扰;sheet 被强制为数组后循环处理每个画布;Thumbnails/、Revisions/ 下的图片不提取,避免噪声;最后用正则把连续空行压缩为空行、首尾 trim。
四、完整脚本
- const fs = require('fs');
- const path = require('path');
- const JSZip = require('jszip');
- const { XMLParser } = require('fast-xml-parser');
- const parser = new XMLParser({
- ignoreAttributes: false,
- attributeNamePrefix: '@_',
- isArray: (name) => ['topic','topics','sheet','label'].includes(name),
- textNodeName: '#text'
- });
- function txt(node) {
- if (node == null) return '';
- if (typeof node === 'string') return node;
- if (typeof node === 'number') return String(node);
- if (Array.isArray(node)) return node.map(txt).join('');
- if (typeof node === 'object') {
- if ('#text' in node) return txt(node['#text']);
- if (node.div || node.p || node.span) return untag(node);
- return '';
- }
- return '';
- }
- function untag(node) {
- if (node == null) return '';
- if (typeof node === 'string') return node;
- if (Array.isArray(node)) return node.map(untag).join('');
- if (typeof node === 'object') {
- let s = '';
- for (const k of Object.keys(node)) {
- if (k.startsWith('@_')) continue;
- if (k === 'br') { s += '\n'; continue; }
- if (k === '#text') { s += node[k]; continue; }
- s += untag(node[k]);
- }
- return s;
- }
- return '';
- }
- function findImg(topic) {
- if (!topic) return [];
- const imgs = topic['xhtml:img'];
- if (!imgs) return [];
- const arr = Array.isArray(imgs) ? imgs : [imgs];
- return arr.map(i => {
- const src = i['@_xhtml:src'] || i['@_src'] || '';
- return src.split('/').pop() || '';
- }).filter(Boolean);
- }
- function getNotes(topic) {
- const notes = topic && topic.notes;
- if (!notes) return '';
- if (typeof notes === 'string') return notes.trim();
- if (notes.html) return untag(notes.html).trim();
- return untag(notes).trim();
- }
- function getLabels(topic) {
- const labels = topic && topic.labels;
- if (!labels) return [];
- if (labels.label) return labels.label.map(t => txt(t)).filter(Boolean);
- return [];
- }
- function imgMd(name, alt, imagesMap, usedImages) {
- if (imagesMap.has(name)) {
- usedImages.add(name);
- return '';
- }
- return '[图片: ' + name + ']';
- }
- function walkTopic(topic, depth, out, isRoot, parentTitle, imagesMap, usedImages) {
- const originalTitle = txt(topic.title).replace(/\s*\n\s*/g, ' ').trim();
- const imgs = findImg(topic);
- let isImageOnly = false;
- let title = originalTitle;
- let hasContent = true;
- if (!title) {
- if (imgs.length) {
- isImageOnly = true;
- title = imgs.map(n => imgMd(n, parentTitle, imagesMap, usedImages)).join(' ');
- } else {
- hasContent = false;
- }
- }
- if (isRoot) {
- if (title.startsWith('SystemAnalyst-')) title = title.slice('SystemAnalyst-'.length);
- if (title) out.push('# ' + title + '\n');
- } else if (hasContent) {
- let line = ' '.repeat(depth) + '- ' + title;
- const labels = getLabels(topic);
- if (labels.length) line += ' `[' + labels.join(', ') + ']`';
- out.push(line);
- }
- const notes = getNotes(topic);
- if (notes) {
- const ind = isRoot ? 0 : (hasContent ? depth + 1 : depth);
- for (const ln of notes.split('\n')) out.push(' '.repeat(ind) + '> ' + ln.trim());
- }
- if (imgs.length && !isImageOnly) {
- const ind = isRoot ? 1 : depth + 1;
- const alt = originalTitle || parentTitle || '图片';
- for (const n of imgs) {
- out.push(' '.repeat(ind) + '- ' + imgMd(n, alt, imagesMap, usedImages));
- }
- }
- const children = topic.children;
- if (children && children.topics) {
- for (const tn of children.topics) {
- const subs = tn.topic || [];
- const childDepth = isRoot ? 0 : (hasContent ? depth + 1 : depth);
- const nextParent = (title && !isImageOnly) ? title : parentTitle;
- for (const c of subs) walkTopic(c, childDepth, out, false, nextParent, imagesMap, usedImages);
- }
- }
- }
- function buildImageMap(zip) {
- const map = new Map();
- for (const name of Object.keys(zip.files)) {
- const entry = zip.files[name];
- if (entry.dir) continue;
- if (/Thumbnails|Revisions/i.test(name)) continue;
- if (/\.(png|jpe?g|gif|svg|webp|bmp)$/i.test(name)) {
- map.set(name.split('/').pop(), name);
- }
- }
- return map;
- }
- function convert(xml, imagesMap, usedImages) {
- const parsed = parser.parse(xml);
- const content = parsed['xmap-content'] || parsed['map'];
- const sheets = content.sheet || [];
- const md = [];
- for (const sheet of sheets) {
- const rootTopics = sheet.topic || [];
- for (const root of rootTopics) walkTopic(root, 0, md, true, '', imagesMap, usedImages);
- md.push('');
- }
- return md.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\n{2,}$/, '\n').trim() + '\n';
- }
- async function main() {
- const base = "c:/workspace/gitee/system-analyst-xmind/systemAnalyst";
- const dirs = ['案例分析', '综合知识', '论文'];
- let count = 0, imgCount = 0;
- for (const d of dirs) {
- const dp = path.join(base, d);
- for (const f of fs.readdirSync(dp)) {
- if (!f.toLowerCase().endsWith('.xmind')) continue;
- const full = path.join(dp, f);
- const data = fs.readFileSync(full);
- const zip = await JSZip.loadAsync(data);
- const cxml = zip.file('content.xml');
- if (!cxml) { console.log('SKIP (no content.xml):', f); continue; }
- const xml = await cxml.async('string');
- const imagesMap = buildImageMap(zip);
- const usedImages = new Set();
- const md = convert(xml, imagesMap, usedImages);
- const outPath = path.join(dp, f.replace(/\.xmind$/i, '.md'));
- fs.writeFileSync(outPath, md, 'utf-8');
- if (usedImages.size) {
- const imgDir = path.join(dp, 'images');
- fs.mkdirSync(imgDir, { recursive: true });
- for (const n of usedImages) {
- const internal = imagesMap.get(n);
- if (internal) {
- const buf = await zip.file(internal).async('nodebuffer');
- fs.writeFileSync(path.join(imgDir, n), buf);
- imgCount++;
- }
- }
- }
- count++;
- console.log('OK', path.relative(base, outPath), '| 图片:', usedImages.size);
- }
- }
- console.log('Converted', count, 'files, extracted', imgCount, 'images.');
- }
- main().catch(e => { console.error(e); process.exit(1); });
复制代码
五、使用方法
把脚本放在分类目录下,直接执行 node xmind2md.js 即可。脚本会遍历 base 与 dirs 指定的子目录,把所有 .xmind 重新生成 .md 并刷新 images/,已生成文件会被覆盖。新增导图时,把新的 .xmind 放进对应目录再跑一次命令,不需要改脚本。如果 node_modules/ 被清理,重新执行 npm install jszip fast-xml-parser 即可。
六、扩展与已知限制
若将来遇到新版 XMind 的 content.json,可以在 main 中优先尝试读取 content.json,再写一套对应的 walkTopicJson(结构为 root.children.attached[] 数组),与现有 XML 分支并存。需要严格标题层级时,可在 walkTopic 中按 depth 切换 # 的数量,但 Markdown 标题最多 6 级,超出仍应退回列表。当前所有图片集中在各分类目录的 images/ 下,如需按单个 xmind 分目录,只要改 imgDir 的拼接逻辑。base 与 dirs 目前写死在脚本内,可改成命令行参数以适配其他目录。
需要说明的限制也有几条:脚本只支持 content.xml 旧版格式;图片按 xmind 生成的随机文件名保存,可读性较弱,想语义化命名需要额外的映射;同一分类目录下若不同 xmind 产生同名图片会被后写入者覆盖,好在随机名重复概率极低,实际未发生;导图中的浮动主题(detached)与关系线等高级元素未纳入转换,仅处理 attached 主分支结构。 |