查看: 155|回复: 3

Node脚本用JSZip解析XMind转Markdown并提取图片

[复制链接]
发表于 2 小时前 | 显示全部楼层 |阅读模式
把 .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。安装命令:
  1. 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 标签:
  1. <topic id="...">
  2.   <xhtml:img xhtml:src="xap:attachments/767o3sb2dkk7gkj64ckn5ic077.png" .../>
  3. </topic>
复制代码

注意 xap: 只是 XML 命名空间前缀,并不是真实路径,去掉后 attachments/xxx.png 才是 ZIP 内部图片的实际地址。

映射到 Markdown 的规则为:根主题输出为一级标题,并去掉冗余的 SystemAnalyst- 前缀;一级及以下主题用嵌套无序列表表示,以两空格缩进表达层级;notes 笔记缩进一级后逐行输出为 > 引用块;labels 标签在行尾追加;图片主题输出为 ![alt](images/文件名) 的相对路径形式,纯图片且无标题的节点直接用图片语法作为列表项。图片统一写成 images/文件名,图片文件放在 md 同级目录的 images/ 中,这样 md 移动到哪、只要 images/ 一起移动,引用就一直有效。

三、核心实现

1. 用 JSZip 在内存中读取 XML

把 xmind 当普通 zip 载入,读回 content.xml 字符串,全程不落中间解压文件,干净且高效:
  1. const JSZip = require('jszip');
  2. const data = fs.readFileSync(xmindPath);
  3. const zip = await JSZip.loadAsync(data);
  4. const xml = await zip.file('content.xml').async('string');
复制代码

2. fast-xml-parser 的关键配置
  1. const { XMLParser } = require('fast-xml-parser');
  2. const parser = new XMLParser({
  3.   ignoreAttributes: false,
  4.   attributeNamePrefix: '@_',
  5.   isArray: (name) => ['topic','topics','sheet','label'].includes(name),
  6.   textNodeName: '#text'
  7. });
复制代码

其中 isArray 是最重要的一项。fast-xml-parser 默认的返回策略是“只有一个子节点时给对象、多个时给数组”,一旦节点数量变化,递归代码就会因为类型不一致而崩溃。强制 topic、topics、sheet、label 永远返回数组后,遍历逻辑才稳定。ignoreAttributes 必须为 false 才能拿到 type="attached" 这类属性,attributeNamePrefix 给属性名加 @_ 前缀便于和子节点区分。解析后的访问路径大致是:
  1. parsed['xmap-content'].sheet[i].topic[j]
  2.   .children.topics[k].topic[m]
复制代码

3. 递归生成 Markdown

walkTopic 是转换的核心,靠 depth 参数控制缩进(' '.repeat(depth) 生成两空格层级),靠 parentTitle 把父标题传给子节点,供图片主题生成 alt 文本时使用。标题内的换行会折叠为单个空格,避免破坏 Markdown 列表结构:
  1. function walkTopic(topic, depth, out, isRoot, parentTitle, imagesMap, usedImages) {
  2.   const originalTitle = txt(topic.title).replace(/\s*\n\s*/g, ' ').trim();
  3.   const imgs = findImg(topic);
  4.   let isImageOnly = false;
  5.   let title = originalTitle;
  6.   let hasContent = true;
  7.   if (!title) {
  8.     if (imgs.length) {
  9.       isImageOnly = true;
  10.       title = imgs.map(n => imgMd(n, parentTitle, imagesMap, usedImages)).join(' ');
  11.     } else {
  12.       hasContent = false;
  13.     }
  14.   }
  15.   if (isRoot) {
  16.     if (title.startsWith('SystemAnalyst-')) title = title.slice('SystemAnalyst-'.length);
  17.     if (title) out.push('# ' + title + '\n');
  18.   } else if (hasContent) {
  19.     let line = ' '.repeat(depth) + '- ' + title;
  20.     const labels = getLabels(topic);
  21.     if (labels.length) line += ' `[' + labels.join(', ') + ']`';
  22.     out.push(line);
  23.   }
  24.   const notes = getNotes(topic);
  25.   if (notes) {
  26.     const ind = isRoot ? 0 : (hasContent ? depth + 1 : depth);
  27.     for (const ln of notes.split('\n')) out.push(' '.repeat(ind) + '> ' + ln.trim());
  28.   }
  29.   if (imgs.length && !isImageOnly) {
  30.     const ind = isRoot ? 1 : depth + 1;
  31.     const alt = originalTitle || parentTitle || '图片';
  32.     for (const n of imgs) {
  33.       out.push(' '.repeat(ind) + '- ' + imgMd(n, alt, imagesMap, usedImages));
  34.     }
  35.   }
  36.   const children = topic.children;
  37.   if (children && children.topics) {
  38.     for (const tn of children.topics) {
  39.       const subs = tn.topic || [];
  40.       const childDepth = isRoot ? 0 : (hasContent ? depth + 1 : depth);
  41.       const nextParent = (title && !isImageOnly) ? title : parentTitle;
  42.       for (const c of subs) walkTopic(c, childDepth, out, false, nextParent, imagesMap, usedImages);
  43.     }
  44.   }
  45. }
复制代码

4. 图片映射与落地

先扫描 zip 建立“文件名 → 内部完整路径”的映射,并排除缩略图与历史版本:
  1. function buildImageMap(zip) {
  2.   const map = new Map();
  3.   for (const name of Object.keys(zip.files)) {
  4.     const entry = zip.files[name];
  5.     if (entry.dir) continue;
  6.     if (/Thumbnails|Revisions/i.test(name)) continue;
  7.     if (/\.(png|jpe?g|gif|svg|webp|bmp)$/i.test(name)) {
  8.       map.set(name.split('/').pop(), name);
  9.     }
  10.   }
  11.   return map;
  12. }
复制代码

转换完成后,只把本文件实际引用到的图片写出到 images/:
  1. fs.mkdirSync(imgDir, { recursive: true });
  2. for (const n of usedImages) {
  3.   const internal = imagesMap.get(n);
  4.   if (internal) {
  5.     const buf = await zip.file(internal).async('nodebuffer');
  6.     fs.writeFileSync(path.join(imgDir, n), buf);
  7.   }
  8. }
复制代码

而图片主题转 Markdown 引用的函数里,一旦命中映射就把文件名记入 usedImages 集合,命不中则降级为占位文本:
  1. function imgMd(name, alt, imagesMap, usedImages) {
  2.   if (imagesMap.has(name)) {
  3.     usedImages.add(name);
  4.     return '![' + (alt || '图片') + '](images/' + name + ')';
  5.   }
  6.   return '[图片: ' + name + ']';
  7. }
复制代码

5. 边界与降级处理

批量转换最怕一个坏节点拖垮整批,脚本做了几处兜底:图片在 zip 中找不到时输出 [图片: 文件名] 占位,不中断任务;无标题且非图片的空节点不输出自身但保留子结构,层级不丢失;xap:attachments/xxx.png 通过取 / 后段得到真实文件名再回 attachments/ 查找,绕开命名空间前缀干扰;sheet 被强制为数组后循环处理每个画布;Thumbnails/、Revisions/ 下的图片不提取,避免噪声;最后用正则把连续空行压缩为空行、首尾 trim。

四、完整脚本
  1. const fs = require('fs');
  2. const path = require('path');
  3. const JSZip = require('jszip');
  4. const { XMLParser } = require('fast-xml-parser');
  5. const parser = new XMLParser({
  6.   ignoreAttributes: false,
  7.   attributeNamePrefix: '@_',
  8.   isArray: (name) => ['topic','topics','sheet','label'].includes(name),
  9.   textNodeName: '#text'
  10. });
  11. function txt(node) {
  12.   if (node == null) return '';
  13.   if (typeof node === 'string') return node;
  14.   if (typeof node === 'number') return String(node);
  15.   if (Array.isArray(node)) return node.map(txt).join('');
  16.   if (typeof node === 'object') {
  17.     if ('#text' in node) return txt(node['#text']);
  18.     if (node.div || node.p || node.span) return untag(node);
  19.     return '';
  20.   }
  21.   return '';
  22. }
  23. function untag(node) {
  24.   if (node == null) return '';
  25.   if (typeof node === 'string') return node;
  26.   if (Array.isArray(node)) return node.map(untag).join('');
  27.   if (typeof node === 'object') {
  28.     let s = '';
  29.     for (const k of Object.keys(node)) {
  30.       if (k.startsWith('@_')) continue;
  31.       if (k === 'br') { s += '\n'; continue; }
  32.       if (k === '#text') { s += node[k]; continue; }
  33.       s += untag(node[k]);
  34.     }
  35.     return s;
  36.   }
  37.   return '';
  38. }
  39. function findImg(topic) {
  40.   if (!topic) return [];
  41.   const imgs = topic['xhtml:img'];
  42.   if (!imgs) return [];
  43.   const arr = Array.isArray(imgs) ? imgs : [imgs];
  44.   return arr.map(i => {
  45.     const src = i['@_xhtml:src'] || i['@_src'] || '';
  46.     return src.split('/').pop() || '';
  47.   }).filter(Boolean);
  48. }
  49. function getNotes(topic) {
  50.   const notes = topic && topic.notes;
  51.   if (!notes) return '';
  52.   if (typeof notes === 'string') return notes.trim();
  53.   if (notes.html) return untag(notes.html).trim();
  54.   return untag(notes).trim();
  55. }
  56. function getLabels(topic) {
  57.   const labels = topic && topic.labels;
  58.   if (!labels) return [];
  59.   if (labels.label) return labels.label.map(t => txt(t)).filter(Boolean);
  60.   return [];
  61. }
  62. function imgMd(name, alt, imagesMap, usedImages) {
  63.   if (imagesMap.has(name)) {
  64.     usedImages.add(name);
  65.     return '![' + (alt || '图片') + '](images/' + name + ')';
  66.   }
  67.   return '[图片: ' + name + ']';
  68. }
  69. function walkTopic(topic, depth, out, isRoot, parentTitle, imagesMap, usedImages) {
  70.   const originalTitle = txt(topic.title).replace(/\s*\n\s*/g, ' ').trim();
  71.   const imgs = findImg(topic);
  72.   let isImageOnly = false;
  73.   let title = originalTitle;
  74.   let hasContent = true;
  75.   if (!title) {
  76.     if (imgs.length) {
  77.       isImageOnly = true;
  78.       title = imgs.map(n => imgMd(n, parentTitle, imagesMap, usedImages)).join(' ');
  79.     } else {
  80.       hasContent = false;
  81.     }
  82.   }
  83.   if (isRoot) {
  84.     if (title.startsWith('SystemAnalyst-')) title = title.slice('SystemAnalyst-'.length);
  85.     if (title) out.push('# ' + title + '\n');
  86.   } else if (hasContent) {
  87.     let line = ' '.repeat(depth) + '- ' + title;
  88.     const labels = getLabels(topic);
  89.     if (labels.length) line += ' `[' + labels.join(', ') + ']`';
  90.     out.push(line);
  91.   }
  92.   const notes = getNotes(topic);
  93.   if (notes) {
  94.     const ind = isRoot ? 0 : (hasContent ? depth + 1 : depth);
  95.     for (const ln of notes.split('\n')) out.push(' '.repeat(ind) + '> ' + ln.trim());
  96.   }
  97.   if (imgs.length && !isImageOnly) {
  98.     const ind = isRoot ? 1 : depth + 1;
  99.     const alt = originalTitle || parentTitle || '图片';
  100.     for (const n of imgs) {
  101.       out.push(' '.repeat(ind) + '- ' + imgMd(n, alt, imagesMap, usedImages));
  102.     }
  103.   }
  104.   const children = topic.children;
  105.   if (children && children.topics) {
  106.     for (const tn of children.topics) {
  107.       const subs = tn.topic || [];
  108.       const childDepth = isRoot ? 0 : (hasContent ? depth + 1 : depth);
  109.       const nextParent = (title && !isImageOnly) ? title : parentTitle;
  110.       for (const c of subs) walkTopic(c, childDepth, out, false, nextParent, imagesMap, usedImages);
  111.     }
  112.   }
  113. }
  114. function buildImageMap(zip) {
  115.   const map = new Map();
  116.   for (const name of Object.keys(zip.files)) {
  117.     const entry = zip.files[name];
  118.     if (entry.dir) continue;
  119.     if (/Thumbnails|Revisions/i.test(name)) continue;
  120.     if (/\.(png|jpe?g|gif|svg|webp|bmp)$/i.test(name)) {
  121.       map.set(name.split('/').pop(), name);
  122.     }
  123.   }
  124.   return map;
  125. }
  126. function convert(xml, imagesMap, usedImages) {
  127.   const parsed = parser.parse(xml);
  128.   const content = parsed['xmap-content'] || parsed['map'];
  129.   const sheets = content.sheet || [];
  130.   const md = [];
  131.   for (const sheet of sheets) {
  132.     const rootTopics = sheet.topic || [];
  133.     for (const root of rootTopics) walkTopic(root, 0, md, true, '', imagesMap, usedImages);
  134.     md.push('');
  135.   }
  136.   return md.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\n{2,}$/, '\n').trim() + '\n';
  137. }
  138. async function main() {
  139.   const base = "c:/workspace/gitee/system-analyst-xmind/systemAnalyst";
  140.   const dirs = ['案例分析', '综合知识', '论文'];
  141.   let count = 0, imgCount = 0;
  142.   for (const d of dirs) {
  143.     const dp = path.join(base, d);
  144.     for (const f of fs.readdirSync(dp)) {
  145.       if (!f.toLowerCase().endsWith('.xmind')) continue;
  146.       const full = path.join(dp, f);
  147.       const data = fs.readFileSync(full);
  148.       const zip = await JSZip.loadAsync(data);
  149.       const cxml = zip.file('content.xml');
  150.       if (!cxml) { console.log('SKIP (no content.xml):', f); continue; }
  151.       const xml = await cxml.async('string');
  152.       const imagesMap = buildImageMap(zip);
  153.       const usedImages = new Set();
  154.       const md = convert(xml, imagesMap, usedImages);
  155.       const outPath = path.join(dp, f.replace(/\.xmind$/i, '.md'));
  156.       fs.writeFileSync(outPath, md, 'utf-8');
  157.       if (usedImages.size) {
  158.         const imgDir = path.join(dp, 'images');
  159.         fs.mkdirSync(imgDir, { recursive: true });
  160.         for (const n of usedImages) {
  161.           const internal = imagesMap.get(n);
  162.           if (internal) {
  163.             const buf = await zip.file(internal).async('nodebuffer');
  164.             fs.writeFileSync(path.join(imgDir, n), buf);
  165.             imgCount++;
  166.           }
  167.         }
  168.       }
  169.       count++;
  170.       console.log('OK', path.relative(base, outPath), '| 图片:', usedImages.size);
  171.     }
  172.   }
  173.   console.log('Converted', count, 'files, extracted', imgCount, 'images.');
  174. }
  175. 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 主分支结构。
回复

使用道具 举报

发表于 1 小时前 | 显示全部楼层

Re: Node脚本用JSZip解析XMind转Markdown并提取图片

这套思路挺实用的,把 .xmind 当成 ZIP 直接读取再递归处理,比依赖专门解析库更灵活;改一个版本也能沿用。几个实践中容易踩到的问题,供参考: 一是版本差异建议先探测再解析。打开 ZIP 后,可以先检查有没有 content.xml,没有的话再看有没有 content.json,不要固定只读其中一个;否则拿到新版 XMind 直接解析成 undefined,报错位置会很难查。 二是图片路径的命名空间前缀既要处理 xap:,也要考虑其他变体。有些文件导出的 src 会带绝对路径或者不同前缀,比较稳妥的做法是只取 attachments/ 之后的部分作为 ZIP 内部路径,再用它去 zip.file 里定位;拼输出文件名时按 ZIP 内原始路径做一次映射,同时用 Set 记录已复制的图片,避免同一张图被多个主题引用时反复写入。 三是同名覆盖问题。不同 sheet 或不同文件夹下可能出现相同文件名的图片,直接取 basename 容易互相覆盖。可以在落地文件名里加一段短哈希,或者保留子目录结构,但映射表要跟着一起改。 四是 content.json 那条分支的遍历逻辑和 XML 不太一样,JSON 里 topic 的子节点直接是 children.attached 数组,处理时最好单独写一层适配,把两种结构归一成同一种内部节点再交给 walkTopic,主逻辑就不用改两遍。 五是
回复 支持 反对

使用道具 举报

发表于 1 小时前 | 显示全部楼层

Re: Node脚本用JSZip解析XMind转Markdown并提取图片

思路很实用,把 xmind 当成 zip 来读确实能绕开很多“私有格式”的误解,而且全程内存解压、不落中间文件这点对批量处理很友好。几个技术点讲得也清楚,尤其是 isArray 那个坑,fast-xml-parser 默认单子节点给对象、多子节点给数组,递归里类型一变就很容易崩,强制 topic、topics、sheet 返回数组是稳的做法;xap: 只是命名空间前缀、去掉后才是 attachments/ 真实路径这个提醒也很关键,很多人会在这里卡住。 图片统一落到 md 同级的 images/ 并用相对路径引用,迁移时整体带走就能一直有效,这个设计比散落引用靠谱。notes 缩进一级转引用块、labels 行尾追加的映射规则也比较符合 Markdown 阅读习惯。 有两点想补充交流一下:一是帖子里提到新版 XMind Zen 是 content.json,但核心实现目前只读了 content.xml,批量跑目录时如果混入 Zen 文件可能会直接报错,建议先判断 zip 里有没有 content.xml,没有就走 content.json 分支或者给出跳过提示。二是 attachments/ 里可能有未被导图引用的图片,按 usedImages 集合只复制实际引用到的文件会更干净,同时留意不同附件重名的情况,落到 images/ 前最好做一次去重或改名,避免后写覆盖前写。 另外 w
回复 支持 反对

使用道具 举报

发表于 1 小时前 | 显示全部楼层

Re: Node脚本用JSZip解析XMind转Markdown并提取图片

思路挺实用的。之前我总觉得 xmind 得靠专用库或者客户端才能读,没想到本质就是 zip,改后缀直接解开这招最直观,也省了装一堆依赖。 关于 content.xml 和 content.json 两个版本,这点提得很关键;但示例里直接调用 zip.file('content.xml'),碰到 XMind Zen 或更新的文件会返回 null,后面再 .async 就直接抛错了。建议先探测一下 content.xml 存不存在,没有再走 content.json 那条路;或者先用 file(/content\.(xml|json)/) 判断再分派,报错信息也能友好点。 isArray 那段说到点上了。fast-xml-parser 默认单子节点给对象,确实最容易踩坑,尤其 topic 数量刚好从 1 变 2 的时候,代码突然就崩。顺手提一句,新版 isArray 的回调签名是 (tagName, jPath, isLeafNode, isAttribute),用 jPath 判断比如 xxx.children.topics.topic 会更准,能避免别处同名标签被误判成数组。 xap: 前缀这个提醒也很关键,很多人就卡在找不到图片。不过如果 src 里带空格或中文,最好先 decodeURIComponent 再拼 ZIP 内路径,不然 zip.file 找不到。输出到 Markd
回复 支持 反对

使用道具 举报

您需要登录后才可以回帖 登录 | 注册

本版积分规则

指导单位

江苏省公安厅

江苏省通信管理局

浙江省台州刑侦支队

DEFCON GROUP 86025

Hacking Group 021A

旗下站点

态势感知中心

应急响应中心

红盟安全

联系我们

官方QQ群:112851260

官方邮箱:security#ihonker.org(#改成@)

官方核心成员

关注微信公众号

Archiver|手机版|小黑屋| ( 沪ICP备2021026908号 )

GMT+8, 2026-11-7 05:01 , Processed in 0.024203 second(s), 28 queries , Gzip On.

Powered by ihonker.com

Copyright © 2015-现在.

  • 返回顶部