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 }, }