import type JSZip from "jszip"; import { getXmindMarker, xmindMarkerName } from "./xmind-markers"; import { getTopicEntries } from "./topic-details"; import { SPECIAL_MIND_LAYOUTS, specialLayoutForStructure, } from "./mind-special-layout"; import type { PlaitElement } from "@plait/core"; import { ArrowLineMarkerType, ArrowLineShape, BasicShapes, createArrowLineElement, createGeometryElementWithoutText, } from "@plait/draw"; import { MindLayoutType } from "@plait/layouts"; import { createMindElement, BranchShape, MindElementShape, type MindElement, type PlaitMind, } from "@plait/mind"; export class MindMapImportError extends Error {} export type ImportedMindMap = { sheets: { id: string; title: string; children: PlaitElement[] }[]; warnings: string[]; }; type Data = Record; const record = (value: unknown): Data => value && typeof value === "object" && !Array.isArray(value) ? (value as Data) : {}; const list = (value: unknown): Data[] => Array.isArray(value) ? value.filter( (item) => item && typeof item === "object" && !Array.isArray(item) ) : []; const text = (value: unknown, fallback = "") => typeof value === "string" ? value.trim() || fallback : fallback; const children = (element: Element, name: string) => Array.from(element.children).filter((child) => child.localName === name); const first = (element: Element, name: string) => children(element, name)[0]; const color = (value: unknown) => typeof value === "string" && /^#[\da-f]{3,8}$/i.test(value) ? value : undefined; function spreadsheetColumns(topic: Data) { const extension = list(topic.extensions).find( (item) => item.provider === "org.xmind.ui.spreadsheet" ); const result: string[] = []; const visit = (value: unknown) => { if (Array.isArray(value)) { value.forEach(visit); return; } const item = record(value); if (item.name === "column") { const value = text(item.content); if (value) result.push(value); return; } Object.values(item).forEach(visit); }; visit(extension?.content); return [...new Set(result)]; } // XMind's public model: https://github.com/xmindltd/xmind-model/tree/master/schemas const layouts: Record = { "org.xmind.ui.map.unbalanced": MindLayoutType.standard, "org.xmind.ui.map.clockwise": MindLayoutType.standard, "org.xmind.ui.logic.right": MindLayoutType.right, "org.xmind.ui.logic.left": MindLayoutType.left, "org.xmind.ui.org-chart.down": MindLayoutType.downward, "org.xmind.ui.org-chart.up": MindLayoutType.upward, "org.xmind.ui.tree.right": MindLayoutType.rightBottomIndented, "org.xmind.ui.tree.left": MindLayoutType.leftBottomIndented, }; function parseXml(source: string) { const doc = new DOMParser().parseFromString(source, "application/xml"); if (doc.getElementsByTagName("parsererror").length) throw new MindMapImportError("XMind 文件中的 XML 内容已损坏"); return doc; } function xmlSheets(source: string, stylesSource?: string): Data[] { const styles = new Map(); if (stylesSource) { for (const element of Array.from( parseXml(stylesSource).getElementsByTagNameNS("*", "style") )) { const properties: Data = {}; for (const property of Array.from(element.children)) { for (const attribute of Array.from(property.attributes)) properties[attribute.name] = attribute.value; } styles.set(element.getAttribute("id") || "", properties); } } const style = (element: Element) => ({ properties: styles.get(element.getAttribute("style-id") || "") || {}, }); const convert = (element: Element, depth = 0): Data => { if (depth > 128) throw new MindMapImportError("主题层级超过 128 层,请拆分后导入"); const childGroups: Data = {}; const container = first(element, "children"); if (container) for (const group of children(container, "topics")) { const kind = group.getAttribute("type") || "attached"; childGroups[kind] = children(group, "topic").map((child) => convert(child, depth + 1) ); } const collection = (containerName: string, itemName: string) => { const parent = first(element, containerName); return parent ? children(parent, itemName).map((item) => ({ ...Object.fromEntries( Array.from(item.attributes).map((attr) => [attr.name, attr.value]) ), title: first(item, "title")?.textContent || "", style: style(item), topicId: item.getAttribute("topic-id"), })) : []; }; const notes = first(element, "notes"); const plainNote = notes && first(notes, "plain"); const labels = first(element, "labels"); const image = first(element, "image") || Array.from(element.children).find((child) => child.localName === "img"); const position = first(element, "position"); return { id: element.getAttribute("id"), title: first(element, "title")?.textContent, structureClass: element.getAttribute("structure-class"), branch: element.getAttribute("branch"), children: childGroups, style: style(element), notes: (plainNote || notes)?.textContent?.trim() || undefined, href: element.getAttributeNS("http://www.w3.org/1999/xlink", "href") || undefined, labels: labels ? children(labels, "label") .map((label) => label.textContent || "") .filter(Boolean) : undefined, image: image ? { src: image.getAttribute("src") || image.getAttributeNS("http://www.w3.org/1999/xlink", "href"), width: image.getAttribute("width"), height: image.getAttribute("height"), } : undefined, position: position ? { x: Number(position.getAttribute("x")), y: Number(position.getAttribute("y")), } : undefined, summaries: collection("summaries", "summary"), boundaries: collection("boundaries", "boundary"), markers: collection("marker-refs", "marker-ref").map((marker) => ({ markerId: record(marker)["marker-id"], })), extensions: first(element, "extensions") ? Array.from(first(element, "extensions")!.children).map( (extension) => { const extensionItem = (item: Element): Data => ({ name: item.localName, content: item.children.length ? Array.from(item.children).map(extensionItem) : item.textContent, }); return { provider: extension.getAttribute("provider"), content: Array.from(extension.children).map(extensionItem), }; } ) : [], }; }; const doc = parseXml(source); return Array.from(doc.getElementsByTagNameNS("*", "sheet")).map((sheet) => { const root = first(sheet, "topic"); const relationships = first(sheet, "relationships"); return { title: first(sheet, "title")?.textContent, rootTopic: root ? convert(root) : undefined, relationships: relationships ? children(relationships, "relationship").map((item) => ({ end1Id: item.getAttribute("end1"), end2Id: item.getAttribute("end2"), title: first(item, "title")?.textContent, style: style(item), })) : [], }; }); } function parseRange(value: unknown, count: number) { const match = text(value).match(/^\((\d+)\s*,\s*(\d+)\)$/); if (!match) return null; const start = Number(match[1]); const end = Number(match[2]); return start <= end && end < count ? { start, end } : null; } function convertSheets(sheets: Data[], fallbackTitle: string): ImportedMindMap { const warnings = new Set(); let nodeCount = 0; if ( !sheets.length || sheets.some((sheet) => !Object.keys(record(sheet.rootTopic)).length) ) { throw new MindMapImportError("XMind 文件没有有效的工作表或中心主题"); } return { sheets: sheets.map((sheet, index) => { const topicIds = new Map(); const floating: PlaitElement[] = []; const annotations: PlaitElement[] = []; const theme = record(sheet.theme); const palette = text(record(record(theme.map).properties)["multi-line-colors"] || record(record(theme.map).properties)["color-list"]).split(/\s+/).map(color).filter((value): value is string => Boolean(value)); const build = ( topic: Data, depth: number, root = false, floatingRoot = false, inheritedBranchColor = "" ): MindElement => { if (++nodeCount > 10_000 || depth > 128) throw new MindMapImportError( "文件超过 10000 个主题或 128 层,请拆分后导入" ); const element = createMindElement(text(topic.title, "未命名主题"), {}); if (typeof topic.id === "string") topicIds.set(topic.id, element.id); const groups = Array.isArray(topic.children) ? { attached: topic.children } : record(topic.children); const structure = text(topic.structureClass); const specialLayout = specialLayoutForStructure(structure); if (specialLayout) element.specialLayout = specialLayout; if (root && specialLayout === "spreadsheet") { const columns = spreadsheetColumns(topic); if (columns.length) element.spreadsheetColumns = columns; } if (specialLayout && !root) warnings.add("子树的特殊结构暂未单独排布"); if (structure && !layouts[structure] && !specialLayout) warnings.add("部分特殊结构已转换为逻辑图"); if (structure || root) element.layout = specialLayout ? (SPECIAL_MIND_LAYOUTS[specialLayout].base as MindLayoutType) : layouts[structure] || MindLayoutType.right; element.isCollapsed = topic.branch === "folded"; const properties = { ...record( record( theme[ floatingRoot ? "floatingTopic" : root ? "centralTopic" : depth === 1 ? "mainTopic" : "subTopic" ] ).properties ), ...record(record(topic.style).properties), }; const fill = color(properties["svg:fill"]); if (properties["fill-pattern"] === "none") element.fill = "transparent"; else if (fill) element.fill = fill; else if (properties["svg:fill"] === "inherited" && inheritedBranchColor) element.fill = inheritedBranchColor; const stroke = color(properties["border-line-color"]); if (stroke) element.strokeColor = stroke; const branch = color(properties["line-color"]); if (branch) element.branchColor = branch; else if (inheritedBranchColor) element.branchColor = inheritedBranchColor; const borderWidth = Number.parseFloat( String(properties["border-line-width"] || "") ); if (Number.isFinite(borderWidth)) element.strokeWidth = Math.max(0, Math.min(16, borderWidth)); if (borderWidth === 0 || properties["border-line-pattern"] === "none") element.strokeColor = "transparent"; const branchWidth = Number.parseFloat( String(properties["line-width"] || "") ); if (Number.isFinite(branchWidth)) element.branchWidth = Math.max(0, Math.min(16, branchWidth)); if (properties["line-class"] === "org.xmind.branchConnection.elbow") element.branchShape = BranchShape.polyline; else if ( properties["line-class"] === "org.xmind.branchConnection.curve" ) element.branchShape = BranchShape.bight; const shape = text(properties["shape-class"]); if (shape.endsWith(".underline")) element.shape = MindElementShape.underline; else if (shape.endsWith(".roundedRect")) element.shape = MindElementShape.roundRectangle; const fontSize = Number.parseFloat( String(properties["fo:font-size"] || "") ); const leaf = { text: text(topic.title, "未命名主题"), ...(properties["fo:color"] === "inherited" ? { color: "#000000" } : {}), ...(color(properties["fo:color"]) ? { color: color(properties["fo:color"]) } : {}), ...(Number.isFinite(fontSize) ? { "font-size": Math.max(8, Math.min(96, fontSize)) } : {}), ...(properties["fo:font-weight"] === "bold" || Number(properties["fo:font-weight"]) >= 600 ? { bold: true } : {}), ...(properties["fo:font-style"] === "italic" ? { italic: true } : {}), ...(properties["fo:text-decoration"] === "underline" ? { underlined: true } : {}), ...(typeof properties["fo:font-family"] === "string" ? { "font-family": properties["fo:font-family"] } : {}), }; element.data.topic.children = [leaf]; const metadata: Data = {}; for (const key of ["notes", "href", "labels", "markers", "image", "extensions"]) if (topic[key] !== undefined) metadata[key] = topic[key]; if (Object.keys(metadata).length) element.xmind = metadata; const emojis: string[] = []; for (const marker of list(topic.markers)) { const id = text(marker.markerId); if (getXmindMarker(id)) emojis.push(xmindMarkerName(id)); else warnings.add("部分图标已保留为数据,暂不显示"); } if (emojis.length) element.data.emojis = emojis.map((name) => ({ name })); element.children = list(groups.attached).map((child,index) => build(child, depth + 1, false, false, depth === 0 && palette.length ? palette[index % palette.length] : inheritedBranchColor) ); const attachedCount = element.children.length; if (element.layout === MindLayoutType.standard) { const extension = list(topic.extensions).find( (item) => item.provider === structure ); const right = list(extension?.content).find( (item) => item.name === "right-number" ); const n = Number(right?.content ?? Math.ceil(attachedCount / 2)); element.rightNodeCount = Number.isInteger(n) ? Math.max(0, Math.min(attachedCount, n)) : Math.ceil(attachedCount / 2); } const usedSummaries = new Set(); for (const summary of list(topic.summaries)) { const range = parseRange(summary.range, attachedCount); const summaryTopic = list(groups.summary).find( (item) => item.id === summary.topicId ); if (range && summaryTopic) { const child = build(summaryTopic, depth + 1); child.start = range.start; child.end = range.end; element.children.push(child); usedSummaries.add(text(summaryTopic.id)); } else warnings.add("部分概要范围无法还原"); } for (const boundary of list(topic.boundaries)) { const range = parseRange(boundary.range, attachedCount); if (!range) { warnings.add("部分外框范围无法还原"); continue; } const props = record(record(boundary.style).properties); const shape = createGeometryElementWithoutText( BasicShapes.roundRectangle, [ [0, 0], [1, 1], ], { fill: "transparent", strokeColor: color(props["line-color"]) || "#8b9bb8", strokeWidth: 2, } ); annotations.push({ ...shape, mindAnnotation: { kind: "boundary", topicIds: element.children .slice(range.start, range.end + 1) .map((child) => child.id), padding: 18, }, xmind: { title: boundary.title }, }); if (boundary.title) warnings.add("外框标题已保留为数据,暂不显示"); } const extraTopics = [ ...list(groups.detached), ...list(groups.callout), ...list(groups.summary).filter( (item) => !usedSummaries.has(text(item.id)) ), ]; if (list(groups.callout).length) warnings.add("标注主题已转换为自由主题"); for (const extra of extraTopics) { const detached = build(extra, depth + 1, true, true) as PlaitMind; const pos = record(extra.position); detached.points = [ [ typeof pos.x === "number" && Number.isFinite(pos.x) ? pos.x : 500, typeof pos.y === "number" && Number.isFinite(pos.y) ? pos.y : floating.length * 180, ], ]; floating.push(detached); } if (root) { element.type = "mindmap"; element.isRoot = true; element.points = [[0, 0]]; } return element; }; const root = build(record(sheet.rootTopic), 0, true); const sheetMetadata: Data = {}; for (const key of ["extensions", "nonWorkingDaysSkipping", "topicTasksDisplay", "topicTasksCreatorVisibility"]) if (sheet[key] !== undefined) sheetMetadata[key] = sheet[key]; if (Object.keys(sheetMetadata).length) root.xmind = {...record(root.xmind), sheet: sheetMetadata}; for (const { element } of getTopicEntries([root, ...floating])) { const metadata = record(element.xmind); if (Array.isArray(metadata.extensions)) metadata.extensions = metadata.extensions.map(value => { const ext = record(value); if (ext.provider !== "org.xmind.ui.task") return ext; const content = record(ext.content); return {...ext, content: {...content, ...(Array.isArray(content.dependencies) ? {dependencies: content.dependencies.map(value => { const dep = record(value), mapped = topicIds.get(text(dep.id)); if (!mapped) warnings.add("部分任务依赖指向缺失主题,已保留引用"); return {...dep, id: mapped || dep.id}; })} : {})}}; }); if (Object.keys(metadata).length) element.xmind = metadata; if ( typeof metadata.href === "string" && metadata.href.startsWith("xmind:#") ) { const targetId = topicIds.get(metadata.href.slice(7)); if (targetId) element.xmind = { ...metadata, href: "", topicLinkId: targetId }; else warnings.add("部分主题链接的目标不在当前画布,暂未还原"); } } for (const relationship of list(sheet.relationships)) { const sourceId = topicIds.get(text(relationship.end1Id)); const targetId = topicIds.get(text(relationship.end2Id)); if (!sourceId || !targetId) { warnings.add("部分联系线的目标主题缺失,未能还原"); continue; } const label = { type: "paragraph", children: [{ text: text(relationship.title) }], }; const props = record(record(relationship.style).properties); const shape = createArrowLineElement( ArrowLineShape.curve, [ [0, 0], [1, 1], ], { marker: ArrowLineMarkerType.none }, { marker: ArrowLineMarkerType.arrow }, relationship.title ? [{ position: 0.5, text: label }] : [], { strokeColor: color(props["line-color"]) || "#64748b", strokeWidth: 2, } ); annotations.push({ ...shape, mindAnnotation: { kind: "relationship", sourceId, targetId }, }); } return { id: `xmind-${crypto.randomUUID()}`, title: text( sheet.title, sheets.length === 1 ? fallbackTitle : `画布 ${index + 1}` ), children: [ ...annotations.filter((item) => item.type === "geometry"), root, ...floating, ...annotations.filter((item) => item.type !== "geometry"), ], }; }), warnings: [...warnings], }; } async function restoreResources( result: ImportedMindMap, zip: JSZip ): Promise { const warnings = new Set(result.warnings); const cache = new Map(); let totalBytes = 0; const readResource = async (source: string) => { const path = decodeURIComponent( source.replace(/^xap:/, "").replace(/^\//, "") ); if (/^[a-z]+:/i.test(path) || path.split("/").includes("..")) return null; const file = zip.file(path) || Object.values(zip.files).find( (entry) => !entry.dir && entry.name.endsWith(`/${path}`) ); if (!file) return null; if (cache.has(file.name)) return cache.get(file.name)!; const bytes = await file.async("uint8array"); totalBytes += bytes.length; if (bytes.length > 8 * 1024 * 1024 || totalBytes > 24 * 1024 * 1024) { warnings.add("部分内嵌资源超过大小限制,暂未导入"); return null; } const value = { bytes, url: await file.async("base64") }; cache.set(file.name, value); return value; }; for (const sheet of result.sheets) for (const { element } of getTopicEntries(sheet.children)) { const metadata = record(element.xmind); const image = record(metadata.image); if (typeof image.src === "string") { try { const resource = await readResource(image.src); if (!resource) throw new Error("Missing image"); const b = resource.bytes; const mime = b[0] === 137 && b[1] === 80 ? "image/png" : b[0] === 255 && b[1] === 216 ? "image/jpeg" : String.fromCharCode(...b.slice(0, 3)) === "GIF" ? "image/gif" : String.fromCharCode(...b.slice(0, 4)) === "RIFF" && String.fromCharCode(...b.slice(8, 12)) === "WEBP" ? "image/webp" : null; if (!mime) throw new Error("Unsupported image"); const bitmap = await createImageBitmap( new Blob([new Uint8Array(b)], { type: mime }) ); const requested = Number(image.width); const width = Math.min( 500, requested > 0 ? requested : Math.min(320, bitmap.width) ); const height = (width * bitmap.height) / bitmap.width; bitmap.close(); element.data = { ...record(element.data), image: { url: `data:${mime};base64,${resource.url}`, width, height, }, }; } catch { warnings.add("部分图片缺失、损坏或格式暂不支持"); } } if ( typeof metadata.href === "string" && metadata.href.startsWith("xap:") ) { try { const resource = await readResource(metadata.href); if (!resource) throw new Error("Missing attachment"); const name = decodeURIComponent( metadata.href.split("/").pop() || "附件" ); element.xmind = { ...metadata, href: "", attachment: { name, mimeType: "application/octet-stream", url: `data:application/octet-stream;base64,${resource.url}`, }, }; } catch { warnings.add("部分附件缺失或超过大小限制,暂未导入"); } } } return { ...result, warnings: [...warnings] }; } export async function importXmind( file: File, fallbackTitle: string ): Promise { if (file.size > 50 * 1024 * 1024) throw new MindMapImportError("XMind 文件超过 50 MB,请拆分后导入"); const { default: JSZip } = await import("jszip"); let zip; try { zip = await JSZip.loadAsync(await file.arrayBuffer()); } catch { throw new MindMapImportError("无法打开 XMind 文件,请确认文件完整且未加密"); } const entry = (name: string) => zip.file(name) || Object.values(zip.files).find( (item) => !item.dir && item.name.toLowerCase().endsWith(`/${name}`) ); const json = entry("content.json"); const xml = entry("content.xml"); try { if (json) { const parsed: unknown = JSON.parse(await json.async("string")); const sheets = Array.isArray(parsed) ? list(parsed) : list(record(parsed).sheets); return await restoreResources(convertSheets(sheets, fallbackTitle), zip); } if (xml) return await restoreResources( convertSheets( xmlSheets( await xml.async("string"), await entry("styles.xml")?.async("string") ), fallbackTitle ), zip ); } catch (error) { if (error instanceof MindMapImportError) throw error; throw new MindMapImportError( "XMind 内容解析失败,请在 XMind 中重新保存后再导入" ); } throw new MindMapImportError( "文件中未找到 XMind 内容,请选择有效的 .xmind 文件" ); }