From afe1a2126040d1891f1ce240e23212aed07b41f0 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Thu, 22 Oct 2026 18:27:48 +0200 Subject: [PATCH 1/2] feat(xmind): take over formatting, notes, labels and attached images XMind maps opened with only their titles, links and markers. Take over what the editor can show as well: - fill, text colour, font size, bold and italic, from the map's theme for the central, main and sub topics and from each topic's own style, read from styles.xml for XMind 8 and from content.json for XMind 2020 and later. Where a topic has a fill but no text colour, contrast the text with the fill as XMind does. - plain-text notes as notes, and labels as tags. - images attached to the map, embedded into the node, scaled to the editor's 200 px maximum. Links to attached files cannot be opened and are left out. Shapes, borders, line styles, boundaries, summaries and relationships have no counterpart in the editor and are still left out, as are font families, since XMind's theme fonts are rarely installed. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Andy Scherzinger --- src/__tests__/plugins/xmind.spec.js | 117 +++++++++++++++- src/plugins/xmind.js | 201 ++++++++++++++++++++++------ 2 files changed, 274 insertions(+), 44 deletions(-) diff --git a/src/__tests__/plugins/xmind.spec.js b/src/__tests__/plugins/xmind.spec.js index 73aba72..cc41e97 100644 --- a/src/__tests__/plugins/xmind.spec.js +++ b/src/__tests__/plugins/xmind.spec.js @@ -4,7 +4,7 @@ */ import JSZip from 'jszip' -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import xmind from '../../plugins/xmind.js' describe('xmind plugin', () => { @@ -127,9 +127,124 @@ describe('xmind plugin', () => { xmind.processJsonTopic({ title: 'Leaf' }, obj) expect(obj.children).toBeUndefined() }) + + it('takes over theme and topic styles, notes, labels and images', () => { + const obj = {} + const context = { + images: [], + theme: { + centralTopic: { properties: { 'svg:fill': '#1F3864', 'fo:font-size': '28pt', 'fo:font-weight': '600' } }, + mainTopic: { properties: { 'fo:color': '#333333', 'fo:font-size': '18pt' } }, + }, + } + xmind.processJsonTopic({ + title: 'Root', + style: { properties: { 'fo:color': 'inherited' } }, + children: { + attached: [{ + title: 'Child', + style: { properties: { 'fo:font-style': 'italic', 'fo:font-size': 'inherited' } }, + notes: { plain: { content: 'A note' } }, + labels: ['one', 'two'], + image: { src: 'xap:resources/picture.png' }, + }], + }, + }, obj, context) + expect(obj.data).toEqual({ text: 'Root', background: '#1F3864', color: '#FFFFFF', 'font-size': 28, 'font-weight': 'bold' }) + expect(obj.children[0].data).toEqual({ text: 'Child', color: '#333333', 'font-size': 18, 'font-style': 'italic', note: 'A note', resource: ['one', 'two'] }) + expect(context.images).toEqual([{ data: obj.children[0].data, path: 'resources/picture.png' }]) + }) + }) + + describe('parseStyles', () => { + it('reads styles by id and the style of each family by theme', () => { + const { styles, themes } = xmind.parseStyles(` + + + + `) + expect(styles.orange).toEqual({ 'svg:fill': '#E46C0A' }) + expect(themes.theme.centralTopic).toEqual({ 'svg:fill': '#DCE6F2', 'fo:color': '#376092' }) + }) + }) + + describe('applyFormatting', () => { + it('replaces links to attached images with the image', () => { + const data = {} + const context = { images: [] } + xmind.applyFormatting(data, { style: {}, href: 'xap:attachments/photo.JPG' }, context) + expect(data.hyperlink).toBeUndefined() + expect(context.images).toEqual([{ data, path: 'attachments/photo.JPG' }]) + }) + + it('drops links to other attached files', () => { + const data = {} + const context = { images: [] } + xmind.applyFormatting(data, { style: {}, href: 'xap:attachments/report.pdf' }, context) + expect(data.hyperlink).toBeUndefined() + expect(context.images).toEqual([]) + }) + }) + + describe('contrastColor', () => { + it('picks black on light and white on dark fills', () => { + expect(xmind.contrastColor('#FBF09C')).toBe('#000000') + expect(xmind.contrastColor('#4D86DB')).toBe('#FFFFFF') + expect(xmind.contrastColor('red')).toBe('#000000') + }) + }) + + describe('fitImageSize', () => { + it('scales images down to 200 px keeping the aspect ratio', () => { + expect(xmind.fitImageSize(1024, 768)).toEqual({ width: 200, height: 150 }) + expect(xmind.fitImageSize(300, 600)).toEqual({ width: 100, height: 200 }) + expect(xmind.fitImageSize(80, 60)).toEqual({ width: 80, height: 60 }) + }) }) describe('readDocument', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('takes over the styles and attached images of XMind 8 maps', async () => { + vi.stubGlobal('createImageBitmap', vi.fn().mockResolvedValue({ width: 400, height: 100 })) + const zip = new JSZip() + zip.file('content.xml', `Root + photo.pngA note + `) + zip.file('styles.xml', ` + + + + `) + zip.file('attachments/photo.png', new Uint8Array([137, 80, 78, 71])) + const data = await zip.generateAsync({ type: 'uint8array' }) + + const result = await xmind.readDocument(data) + expect(result.data).toEqual({ text: 'Root', background: '#DCE6F2', color: '#376092' }) + expect(result.children[0].data).toEqual({ + text: 'photo.png', + background: '#E46C0A', + color: '#FFFFFF', + note: 'A note', + resource: ['Tag'], + image: 'data:image/png;base64,iVBORw==', + imageSize: { width: 200, height: 50 }, + }) + }) + + it('leaves out images the browser cannot read', async () => { + vi.stubGlobal('createImageBitmap', vi.fn().mockRejectedValue(new Error('broken'))) + const zip = new JSZip() + zip.file('content.json', JSON.stringify([{ rootTopic: { title: 'Root', image: { src: 'xap:resources/broken.png' } } }])) + zip.file('resources/broken.png', 'not an image') + const data = await zip.generateAsync({ type: 'uint8array' }) + + const result = await xmind.readDocument(data) + expect(result.data).toEqual({ text: 'Root' }) + }) + it('rejects when content.xml is missing in the zip', async () => { await expect(xmind.readDocument('not a zip')).rejects.toBeDefined() }) diff --git a/src/plugins/xmind.js b/src/plugins/xmind.js index 3e9ee1f..b3d1e44 100644 --- a/src/plugins/xmind.js +++ b/src/plugins/xmind.js @@ -7,6 +7,18 @@ import JSZip from 'jszip' import util from '../util.js' +// The editor's default for maxImageWidth and maxImageHeight +const MAX_IMAGE_SIZE = 200 + +const IMAGE_TYPES = { + bmp: 'image/bmp', + gif: 'image/gif', + jpeg: 'image/jpeg', + jpg: 'image/jpeg', + png: 'image/png', + webp: 'image/webp', +} + export default { name: 'xmind', mimes: ['application/vnd.xmind.workbook'], @@ -34,7 +46,7 @@ export default { 'task-7oct': ['progress', 8], 'task-done': ['progress', 9], }, - processTopic(topic, obj) { + processTopic(topic, obj, context = {}, depth = 0) { // 处理文本 obj.data = { text: topic.title, @@ -59,10 +71,14 @@ export default { } } - // 处理超链接 - if (topic['xlink:href']) { - obj.data.hyperlink = topic['xlink:href'] - } + this.applyFormatting(obj.data, { + style: { ...context.theme?.[this.styleFamily(depth)], ...context.styles?.[topic.style_id] }, + note: topic.notes?.plain, + labels: topic.labels?.label, + href: topic['xlink:href'], + image: topic.img?.['xhtml:src'], + }, context) + // 处理子节点 const topics = topic.children && topic.children.topics const subTopics = topics && (topics.topic || (topics[0] && topics[0].topic)) @@ -73,23 +89,22 @@ export default { for (const ii in tmp) { obj.children.push({}) - this.processTopic(tmp[ii], obj.children[ii]) + this.processTopic(tmp[ii], obj.children[ii], context, depth + 1) } } else { // 一个子节点 obj.children = [{}] - this.processTopic(tmp, obj.children[0]) + this.processTopic(tmp, obj.children[0], context, depth + 1) } } }, - toKm(xml) { + toKm(xml, context = {}) { const json = util.xml2json(xml) const result = {} - const sheet = json.sheet - const topic = Array.isArray(sheet) ? sheet[0].topic : sheet.topic - this.processTopic(topic, result) + const sheet = Array.isArray(json.sheet) ? json.sheet[0] : json.sheet + this.processTopic(sheet.topic, result, { ...context, theme: context.themes?.[sheet.theme] }) return result }, - processJsonTopic(topic, obj) { + processJsonTopic(topic, obj, context = {}, depth = 0) { obj.data = { text: topic.title, } @@ -99,47 +114,147 @@ export default { obj.data[type[0]] = type[1] } } - if (topic.href) { - obj.data.hyperlink = topic.href - } + this.applyFormatting(obj.data, { + style: { + ...this.ownProperties(context.theme?.[this.styleFamily(depth)]?.properties), + ...this.ownProperties(topic.style?.properties), + }, + note: topic.notes?.plain?.content, + labels: topic.labels, + href: topic.href, + image: topic.image?.src, + }, context) const children = topic.children?.attached if (children?.length) { obj.children = children.map((child) => { const childObj = {} - this.processJsonTopic(child, childObj) + this.processJsonTopic(child, childObj, context, depth + 1) return childObj }) } }, - jsonToKm(text) { - const sheets = JSON.parse(text) + jsonToKm(text, context = {}) { + const sheet = JSON.parse(text)[0] const result = {} - this.processJsonTopic(sheets[0].rootTopic, result) + this.processJsonTopic(sheet.rootTopic, result, { ...context, theme: sheet.theme }) return result }, - readDocument(file) { - const self = this - return new Promise(function(resolve, reject) { - JSZip.loadAsync(file).then(function(zip) { - // XMind 2020 and later keep the map in content.json and only - // write a warning map to content.xml - const jsonFile = zip.file('content.json') - const contentFile = jsonFile || zip.file('content.xml') - if (contentFile !== null) { - contentFile.async('text').then(function(text) { - try { - const json = jsonFile ? self.jsonToKm(text) : self.toKm(text) - resolve(json) - } catch (e) { - reject(e) - } - }) - } else { - reject(new Error('Content document missing')) - } - }, function(e) { - reject(e) - }) - }) + ownProperties(properties = {}) { + return Object.fromEntries(Object.entries(properties).filter(([, value]) => value !== 'inherited')) + }, + styleFamily(depth) { + return ['centralTopic', 'mainTopic'][depth] ?? 'subTopic' + }, + /** + * Reads the styles of an XMind 8 styles.xml with their XMind property + * names, as XMind 2020 and later write them to content.json + * + * @param {string} xml content of styles.xml + * @return {{styles: object, themes: object}} properties by style id, and the + * properties of each style family by theme id + */ + parseStyles(xml) { + const elements = [...new DOMParser().parseFromString(xml, 'application/xml').getElementsByTagName('style')] + const styles = {} + for (const element of elements) { + const properties = {} + for (const attribute of element.firstElementChild?.attributes ?? []) { + properties[attribute.name] = attribute.value + } + styles[element.getAttribute('id')] = properties + } + const themes = {} + for (const element of elements) { + const defaults = [...element.getElementsByTagName('default-style')] + if (defaults.length) { + themes[element.getAttribute('id')] = Object.fromEntries(defaults.map((d) => [d.getAttribute('style-family'), styles[d.getAttribute('style-id')]])) + } + } + return { styles, themes } + }, + applyFormatting(data, { style, note, labels, href, image }, context) { + if (style['svg:fill'] && style['svg:fill'] !== 'none') { + data.background = style['svg:fill'] + } + if (style['fo:color']) { + data.color = style['fo:color'] + } else if (data.background) { + // XMind contrasts the text with the fill, the editor's own colour may not + data.color = this.contrastColor(data.background) + } + const size = parseFloat(style['fo:font-size']) + if (size > 0) { + data['font-size'] = size + } + if (style['fo:font-weight'] === 'bold' || Number(style['fo:font-weight']) >= 600) { + data['font-weight'] = 'bold' + } + if (style['fo:font-style'] === 'italic') { + data['font-style'] = 'italic' + } + if (note) { + data.note = note + } + if (labels) { + data.resource = util.toArray(labels) + } + // Links to files attached to the map cannot be opened, so show attached images instead + if (href?.startsWith('xap:')) { + image ??= this.imageType(href) && href + } else if (href) { + data.hyperlink = href + } + if (image?.startsWith('xap:') && this.imageType(image)) { + context.images?.push({ data, path: image.slice(4) }) + } + }, + contrastColor(background) { + const rgb = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(background) + if (!rgb) { + return '#000000' + } + const [r, g, b] = rgb.slice(1).map((hex) => parseInt(hex, 16)) + return r * 0.299 + g * 0.587 + b * 0.114 > 150 ? '#000000' : '#FFFFFF' + }, + imageType(path) { + return IMAGE_TYPES[path.split('.').pop().toLowerCase()] + }, + fitImageSize(width, height) { + const scale = Math.min(1, MAX_IMAGE_SIZE / width, MAX_IMAGE_SIZE / height) + return { width: Math.round(width * scale), height: Math.round(height * scale) } + }, + async loadImages(zip, images) { + await Promise.all(images.map(async ({ data, path }) => { + const file = zip.file(path) + if (!file) { + return + } + try { + const { width, height } = await createImageBitmap(await file.async('blob')) + data.image = `data:${this.imageType(path)};base64,${await file.async('base64')}` + data.imageSize = this.fitImageSize(width, height) + } catch { + // not an image the browser can show + } + })) + }, + async readDocument(file) { + const zip = await JSZip.loadAsync(file) + // XMind 2020 and later keep the map in content.json and only + // write a warning map to content.xml + const jsonFile = zip.file('content.json') + const contentFile = jsonFile || zip.file('content.xml') + if (contentFile === null) { + throw new Error('Content document missing') + } + const text = await contentFile.async('text') + const context = { images: [] } + const stylesFile = zip.file('styles.xml') + if (!jsonFile && stylesFile) { + Object.assign(context, this.parseStyles(await stylesFile.async('text'))) + } + const result = jsonFile ? this.jsonToKm(text, context) : this.toKm(text, context) + await this.loadImages(zip, context.images) + return result }, } From 868cecb3c3f47135263004d11a7cec74c44c324c Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Thu, 22 Oct 2026 18:30:52 +0200 Subject: [PATCH 2/2] feat(xmind): open folded branches collapsed XMind marks folded branches with branch="folded", in content.xml as well as in content.json, but they opened expanded. Collapse them, as the FreeMind import does with its folded nodes. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Andy Scherzinger --- src/__tests__/plugins/xmind.spec.js | 16 ++++++++++++++++ src/plugins/xmind.js | 7 ++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/__tests__/plugins/xmind.spec.js b/src/__tests__/plugins/xmind.spec.js index cc41e97..e86c44e 100644 --- a/src/__tests__/plugins/xmind.spec.js +++ b/src/__tests__/plugins/xmind.spec.js @@ -186,6 +186,22 @@ describe('xmind plugin', () => { }) }) + describe('folded branches', () => { + it('collapses folded branches of XMind 8 maps', () => { + const obj = {} + xmind.processTopic({ title: 'Folded', branch: 'folded', children: { topics: { topic: { title: 'Child' } } } }, obj) + expect(obj.data.expandState).toBe('collapse') + expect(obj.children[0].data.expandState).toBeUndefined() + }) + + it('collapses folded branches of XMind 2020 maps', () => { + const obj = {} + xmind.processJsonTopic({ title: 'Folded', branch: 'folded', children: { attached: [{ title: 'Child' }] } }, obj) + expect(obj.data.expandState).toBe('collapse') + expect(obj.children[0].data.expandState).toBeUndefined() + }) + }) + describe('contrastColor', () => { it('picks black on light and white on dark fills', () => { expect(xmind.contrastColor('#FBF09C')).toBe('#000000') diff --git a/src/plugins/xmind.js b/src/plugins/xmind.js index b3d1e44..e91f33f 100644 --- a/src/plugins/xmind.js +++ b/src/plugins/xmind.js @@ -77,6 +77,7 @@ export default { labels: topic.labels?.label, href: topic['xlink:href'], image: topic.img?.['xhtml:src'], + branch: topic.branch, }, context) // 处理子节点 @@ -123,6 +124,7 @@ export default { labels: topic.labels, href: topic.href, image: topic.image?.src, + branch: topic.branch, }, context) const children = topic.children?.attached if (children?.length) { @@ -172,7 +174,7 @@ export default { } return { styles, themes } }, - applyFormatting(data, { style, note, labels, href, image }, context) { + applyFormatting(data, { style, note, labels, href, image, branch }, context) { if (style['svg:fill'] && style['svg:fill'] !== 'none') { data.background = style['svg:fill'] } @@ -198,6 +200,9 @@ export default { if (labels) { data.resource = util.toArray(labels) } + if (branch === 'folded') { + data.expandState = 'collapse' + } // Links to files attached to the map cannot be opened, so show attached images instead if (href?.startsWith('xap:')) { image ??= this.imageType(href) && href