diff --git a/src/__tests__/plugins/xmind.spec.js b/src/__tests__/plugins/xmind.spec.js
index 73aba72..e86c44e 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,140 @@ 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('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')
+ 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..e91f33f 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,15 @@ 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'],
+ branch: topic.branch,
+ }, context)
+
// 处理子节点
const topics = topic.children && topic.children.topics
const subTopics = topics && (topics.topic || (topics[0] && topics[0].topic))
@@ -73,23 +90,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 +115,151 @@ 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,
+ branch: topic.branch,
+ }, 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, branch }, 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)
+ }
+ 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
+ } 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
},
}