import { getXmindMarker } from "./xmind-markers"; import type { PlaitElement } from "@plait/core"; import { getTopicDetails, getTopicEntries, getTopicTitle, } from "./topic-details"; import { isMindAnnotation } from "./mind-annotations"; import { SPECIAL_MIND_LAYOUTS, isSpecialMindLayout, } from "./mind-special-layout"; type Data = Record; type Sheet = { id: string; title: string; children: PlaitElement[] }; export type XmindExportSnapshot = { activeSheetId?: string; children: PlaitElement[]; sheets?: Sheet[]; }; const record = (value: unknown): Data => value && typeof value === "object" && !Array.isArray(value) ? (value as Data) : {}; const array = (value: unknown): Data[] => Array.isArray(value) ? value.map(record) : []; const id = () => crypto.randomUUID(); const layouts: Record = { standard: "org.xmind.ui.map.unbalanced", right: "org.xmind.ui.logic.right", left: "org.xmind.ui.logic.left", downward: "org.xmind.ui.org-chart.down", upward: "org.xmind.ui.org-chart.up", "right-bottom-indented": "org.xmind.ui.tree.right", "left-bottom-indented": "org.xmind.ui.tree.left", }; const summary = (element: PlaitElement) => Number.isInteger(element.start) && Number.isInteger(element.end); function textOf(value: unknown): string { const item = record(value); return typeof item.text === "string" ? item.text : array(item.children).map(textOf).join(""); } /** Modern XMind ZIP model: https://github.com/xmindltd/xmind-model/tree/master/schemas */ export async function exportXmind( snapshot: XmindExportSnapshot, title: string ) { const { default: JSZip } = await import("jszip"); const zip = new JSZip(); const warnings = new Set(); const entries: Record = { "content.json": {}, "metadata.json": {}, }; let resourceNumber = 0; const embed = async (url: string, name: string) => { if (!/^(data:|https?:)/i.test(url)) throw new Error(`无法导出资源:${name}`); const response = await fetch(url); if (!response.ok) throw new Error(`无法读取资源:${name},请确认图片仍可访问`); const bytes = new Uint8Array(await response.arrayBuffer()); const safeName = name.replace(/[\\/:*?"<>|\u0000-\u001f]/g, "_").slice(-120) || "resource"; const path = `resources/${++resourceNumber}/${safeName}`; zip.file(path, bytes); entries[path] = {}; return `xap:${path}`; }; const sourceSheets = snapshot.sheets?.length ? snapshot.sheets : [{ id: "sheet-1", title, children: snapshot.children }]; const sheets = []; for (const source of sourceSheets) { const elements = source.id === (snapshot.activeSheetId || sourceSheets[0].id) ? snapshot.children : source.children; const topics = getTopicEntries(elements); const ids = new Map(topics.map(({ element }) => [element.id, id()])); const converted = new Map(); const parents = new Map(); const build = async (element: PlaitElement): Promise => { const data = record(element.data); const details = getTopicDetails(element); const metadata = record(element.xmind); const topic: Data = { id: ids.get(element.id), class: "topic", title: getTopicTitle(element), }; converted.set(element.id, topic); if (element.isCollapsed) topic.branch = "folded"; if (element.layout) { topic.structureClass = layouts[String(element.layout)] || layouts.right; if (!layouts[String(element.layout)]) warnings.add("部分布局已转换为向右逻辑图"); } if (isSpecialMindLayout(element.specialLayout)) topic.structureClass = SPECIAL_MIND_LAYOUTS[element.specialLayout].structure; if (element.specialLayout === "spreadsheet") { const configured = Array.isArray(element.spreadsheetColumns) ? element.spreadsheetColumns.filter( (column): column is string => typeof column === "string" && Boolean(column.trim()) ) : []; const derived = (element.children || []).flatMap( (child) => getTopicDetails(child).labels ); const columns = [ ...new Set(configured.length ? configured : derived), ]; if (!columns.length) columns.push("列 1", "列 2", "列 3"); topic.extensions = [ { provider: "org.xmind.ui.spreadsheet", content: [ { name: "columns", content: columns.map((column) => ({ name: "column", content: column, })), }, ], }, ]; } if ( element.layout === "standard" && typeof element.rightNodeCount === "number" ) topic.extensions = [ { provider: topic.structureClass, content: [ { name: "right-number", content: String(element.rightNodeCount) }, ], }, ]; // Merge native task extensions with structure extensions; remap every dependency to exported topic IDs. const nativeExtensions = array(metadata.extensions).filter(ext => !["org.xmind.ui.spreadsheet", ...Object.values(layouts)].includes(String(ext.provider))).map(ext => { if (ext.provider !== "org.xmind.ui.task") return ext; const content = record(ext.content); return {...ext, content: {...content, ...(Array.isArray(content.dependencies) ? {dependencies: array(content.dependencies).flatMap(dep => { const target = ids.get(String(dep.id)); if (!target) {warnings.add("指向缺失主题的任务依赖未导出"); return [];} return [{...dep, id: target}]; })} : {})}}; }); if (nativeExtensions.length) topic.extensions = [...array(topic.extensions), ...nativeExtensions]; const leaves = array(record(data.topic).children); const leaf = leaves[0] || {}; if (leaves.length > 1) warnings.add("主题内混合文字格式已按首段样式导出"); const properties: Record = {}; const copy = (key: string, value: unknown) => { if (typeof value === "string" || typeof value === "number") properties[key] = String(value); }; copy("svg:fill", element.fill); if (element.fill === "transparent") properties["fill-pattern"] = "none"; copy("border-line-color", element.strokeColor); copy("border-line-width", element.strokeWidth); copy("line-color", element.branchColor); copy("line-width", element.branchWidth); if (element.branchShape) properties["line-class"] = element.branchShape === "polyline" ? "org.xmind.branchConnection.elbow" : "org.xmind.branchConnection.curve"; if (element.shape) properties["shape-class"] = element.shape === "underline" ? "org.xmind.topicShape.underline" : "org.xmind.topicShape.roundedRect"; copy("fo:color", leaf.color); copy("fo:font-size", leaf["font-size"]); copy("fo:font-family", leaf["font-family"]); if (leaf.bold) properties["fo:font-weight"] = "bold"; if (leaf.italic) properties["fo:font-style"] = "italic"; if (leaf.underlined || leaf.underline) properties["fo:text-decoration"] = "underline"; if (Object.keys(properties).length) topic.style = { id: id(), properties }; if (details.notes) topic.notes = typeof metadata.notes === "object" ? metadata.notes : { plain: { content: details.notes } }; if (details.labels.length) topic.labels = details.labels; const markerSet = new Set(); for (const emoji of array(data.emojis)) { const marker = getXmindMarker(String(emoji.name))?.id; if (marker) markerSet.add(marker); else warnings.add("部分自定义图标尚不能写入 XMind 标记"); } // Retain imported markers that this editor cannot render, but honor removal of supported ones. for (const marker of array(metadata.markers)) if ( typeof marker.markerId === "string" && !getXmindMarker(marker.markerId) ) markerSet.add(marker.markerId); if (markerSet.size) topic.markers = [...markerSet].map((markerId) => ({ markerId })); if (details.topicLinkId && ids.has(details.topicLinkId)) topic.href = `xmind:#${ids.get(details.topicLinkId)}`; else if (details.href) topic.href = details.href; if (details.attachment) { if (topic.href) warnings.add("同时含附件和链接的主题优先导出附件,其他链接暂不导出"); topic.href = await embed( details.attachment.url, details.attachment.name ); } const picture = record(data.image); if (typeof picture.url === "string") { const mime = picture.url.match(/^data:image\/(png|jpeg|gif|webp)/)?.[1] || "png"; topic.image = { src: await embed(picture.url, `image.${mime}`), width: picture.width, height: picture.height, }; } const attached = (element.children || []).filter( (child) => !summary(child) ); const summaries = (element.children || []).filter(summary); const groups: Data = {}; for (const child of attached) parents.set(child.id, element); if (attached.length) groups.attached = await Promise.all(attached.map(build)); if (summaries.length) { groups.summary = await Promise.all(summaries.map(build)); topic.summaries = summaries.map((child) => ({ id: id(), topicId: ids.get(child.id), range: `(${child.start},${child.end})`, })); } if (Object.keys(groups).length) topic.children = groups; return topic; }; const roots = elements.filter((element) => ["mind", "mindmap"].includes(String(element.type)) ); if (!roots.length) throw new Error(`“${source.title}”没有可导出的思维导图主题`); const root = await build(roots[0]); if (roots.length > 1) { const detached = []; const origin = Array.isArray(roots[0].points) ? (roots[0].points[0] as number[]) : [0, 0]; for (const element of roots.slice(1)) { const topic = await build(element); const point = Array.isArray(element.points) ? (element.points[0] as number[]) : [500, 0]; topic.position = { x: point[0] - origin[0], y: point[1] - origin[1] }; detached.push(topic); } root.children = { ...record(root.children), detached }; } const relationships = []; for (const element of elements) { if (roots.includes(element)) continue; if (!isMindAnnotation(element)) { warnings.add("自由画布中的图形、文本和独立图片暂不导出到 XMind"); continue; } const annotation = element.mindAnnotation; const style = { id: id(), properties: { "line-color": String(element.strokeColor || "#64748b"), "line-width": String(element.strokeWidth || 2), }, }; if (annotation.kind === "relationship") { if (!ids.has(annotation.sourceId) || !ids.has(annotation.targetId)) { warnings.add("目标缺失的联系线未导出"); continue; } relationships.push({ id: id(), end1Id: ids.get(annotation.sourceId), end2Id: ids.get(annotation.targetId), title: array(element.texts) .map((item) => textOf(item.text)) .join("\n"), style, controlPoints: { "0": { angle: 0, amount: 0.1 }, "1": { angle: 0, amount: 0.1 }, }, }); } else { const parent = parents.get(annotation.topicIds[0]); const siblings = parent?.children?.filter((child) => !summary(child)) || []; const indices = annotation.topicIds .map((topicId) => siblings.findIndex((child) => child.id === topicId)) .sort((a, b) => a - b); if ( !parent || !indices.length || indices[0] < 0 || indices.at(-1)! - indices[0] + 1 !== indices.length ) { warnings.add("非连续同级主题的外框暂不导出"); continue; } const owner = converted.get(parent.id)!; owner.boundaries = [ ...array(owner.boundaries), { id: id(), range: `(${indices[0]},${indices.at(-1)})`, title: String(record(element.xmind).title || ""), style, }, ]; } } sheets.push({ id: id(), class: "sheet", title: source.title, rootTopic: root, ...record(record(topics.find(entry => !parents.has(entry.element.id))?.element.xmind).sheet), relationships, }); } zip.file("content.json", JSON.stringify(sheets)); zip.file( "metadata.json", JSON.stringify({ dataStructureVersion: "2", creator: { name: "SnapGen", version: "1.0" }, layoutEngineVersion: "4", }) ); zip.file("manifest.json", JSON.stringify({ "file-entries": entries })); return { blob: await zip.generateAsync({ type: "blob", compression: "DEFLATE", mimeType: "application/vnd.xmind.workbook", }), warnings: [...warnings], }; }