type MindMapData = { data: Record; children?: MindMapData[]; }; const readBlobAsDataUrl = (blob: Blob): Promise => new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(String(reader.result || "")); reader.onerror = (err) => reject(err); reader.readAsDataURL(blob); }); const getImageSizeFromBlob = async (blob: Blob): Promise<{ width: number; height: number } | null> => { try { if (typeof createImageBitmap === "function") { const bmp = await createImageBitmap(blob); const res = { width: bmp.width, height: bmp.height }; try { bmp.close(); } catch { // ignore } return res; } } catch { // ignore } return new Promise((resolve) => { const url = URL.createObjectURL(blob); const img = new Image(); img.onload = () => { const res = { width: img.naturalWidth, height: img.naturalHeight }; URL.revokeObjectURL(url); resolve(res); }; img.onerror = () => { URL.revokeObjectURL(url); resolve(null); }; img.src = url; }); }; const walkElements = (root: Element): Element[] => { const stack: Element[] = [root]; const out: Element[] = []; while (stack.length) { const el = stack.pop()!; out.push(el); for (let i = el.children.length - 1; i >= 0; i--) { stack.push(el.children[i] as Element); } } return out; }; const findFirstByLocalName = (root: Element, name: string): Element | null => { for (const el of walkElements(root)) { if (el.localName === name) return el; } return null; }; const findFirstChildByLocalName = (root: Element, name: string): Element | null => { for (const el of Array.from(root.children)) { if ((el as Element).localName === name) return el as Element; } return null; }; const findChildrenByLocalName = (root: Element, name: string): Element[] => Array.from(root.children).filter((el) => (el as Element).localName === name) as Element[]; const getTopicPlainText = (topicEl: Element): string => { const textEl = findFirstChildByLocalName(topicEl, "Text"); const raw = textEl?.getAttribute("PlainText") ?? ""; return raw; }; const getTopicHyperlink = (topicEl: Element): { url: string; title: string } | null => { const linkEl = findFirstChildByLocalName(topicEl, "Hyperlink"); if (!linkEl) return null; const url = linkEl.getAttribute("Url") ?? ""; const title = linkEl.getAttribute("Title") ?? ""; if (!url) return null; return { url, title }; }; const guessMimeFromImageType = (imageType: string): string => { const t = (imageType || "").toLowerCase(); if (t.includes("png")) return "image/png"; if (t.includes("jpeg") || t.includes("jpg")) return "image/jpeg"; if (t.includes("gif")) return "image/gif"; if (t.includes("bmp")) return "image/bmp"; if (t.includes("svg")) return "image/svg+xml"; return "application/octet-stream"; }; const parseMmArchiveUriToZipPath = (uri: string): string | null => { // 常见格式:mmarch://bin/.bin const raw = String(uri || ""); const lower = raw.toLowerCase(); const prefix = "mmarch://"; if (!lower.startsWith(prefix)) return null; let rest = raw.slice(prefix.length); while (rest.startsWith("/")) rest = rest.slice(1); return rest || null; }; const getTopicImageInfo = (topicEl: Element): { uri: string; mime: string } | null => { const oneImage = findFirstChildByLocalName(topicEl, "OneImage"); if (!oneImage) return null; const imageEl = findFirstByLocalName(oneImage, "Image"); if (!imageEl) return null; const imageDataEl = findFirstByLocalName(imageEl, "ImageData"); const uriEl = imageEl ? findFirstByLocalName(imageEl, "Uri") : null; const uri = (uriEl?.textContent ?? "").trim(); if (!uri) return null; const mime = guessMimeFromImageType(imageDataEl?.getAttribute("ImageType") ?? ""); return { uri, mime }; }; const compactTree = (node: MindMapData, isRoot = false): MindMapData => { const children = (node.children ?? []).map((c) => compactTree(c, false)); const data = node.data ?? {}; const hasContent = Boolean(String(data.text ?? "").trim()) || Boolean(String((data as Record).hyperlink ?? "").trim()) || Boolean(String((data as Record).note ?? "").trim()) || Boolean(String((data as Record).image ?? "").trim()) || Boolean(((data as Record).tag as unknown[] | undefined)?.length) || Boolean(String((data as Record).attachmentUrl ?? "").trim()); if (!isRoot && !hasContent && children.length === 1) { return children[0]; } return { ...node, children }; }; export const parseMindManagerMmapFile = async (file: File): Promise => { const JSZip = (await import("jszip")).default; const zip = await JSZip.loadAsync(file); const xmlFile = zip.file("Document.xml"); if (!xmlFile) { throw new Error("Document.xml 不存在"); } const xmlText = await xmlFile.async("string"); const xmlDoc = new DOMParser().parseFromString(xmlText, "application/xml"); if (xmlDoc.getElementsByTagName("parsererror").length > 0) { throw new Error("Document.xml 解析失败"); } const docEl = xmlDoc.documentElement; if (!docEl) throw new Error("Document.xml 内容为空"); const defaultsGroup = findFirstByLocalName(docEl, "RootTopicDefaultsGroup"); const defaultTextEl = defaultsGroup ? findFirstByLocalName(defaultsGroup, "DefaultText") : null; const defaultRootText = defaultTextEl?.getAttribute("PlainText") ?? "中心主题"; const oneTopicEl = findFirstByLocalName(docEl, "OneTopic"); const rootTopicEl = oneTopicEl ? findFirstChildByLocalName(oneTopicEl, "Topic") : null; if (!rootTopicEl) { throw new Error("未找到根主题"); } const binCache = new Map(); const resolveBinToImageDataUrl = async ( uri: string, mime: string, ): Promise<{ dataUrl: string; width: number; height: number } | null> => { const zipPath = parseMmArchiveUriToZipPath(uri); if (!zipPath) return null; if (binCache.has(zipPath)) return binCache.get(zipPath)!; const entry = zip.file(zipPath) ?? zip.file(`/${zipPath}`); if (!entry) return null; const bytes = await entry.async("uint8array"); const rawBuffer = bytes.buffer as ArrayBuffer; const sliced = rawBuffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); const blob = new Blob([sliced], { type: mime }); const dataUrl = await readBlobAsDataUrl(blob); const size = await getImageSizeFromBlob(blob); const width = size?.width ?? 0; const height = size?.height ?? 0; const res = { dataUrl, width, height }; binCache.set(zipPath, res); return res; }; const walkTopic = async (topicEl: Element, isRoot: boolean): Promise => { const text = getTopicPlainText(topicEl); const hyperlink = getTopicHyperlink(topicEl); const imageInfo = getTopicImageInfo(topicEl); const nodeData: Record = { text: text ?? "", }; if (hyperlink?.url) { nodeData.hyperlink = hyperlink.url; if (hyperlink.title) nodeData.hyperlinkTitle = hyperlink.title; // MindManager 常见:文本为空但带链接标题,作为节点可见文本更符合导入预期 if (!String(nodeData.text || "").trim() && hyperlink.title) { nodeData.text = hyperlink.title; } } if (imageInfo?.uri) { const resolved = await resolveBinToImageDataUrl(imageInfo.uri, imageInfo.mime); if (resolved?.dataUrl) { nodeData.image = resolved.dataUrl; nodeData.imageSize = { width: resolved.width || 0, height: resolved.height || 0, custom: true, }; nodeData.imgPlacement = "top"; } } if (isRoot && !String(nodeData.text || "").trim()) { nodeData.text = defaultRootText || "中心主题"; } const subTopicsEl = findFirstChildByLocalName(topicEl, "SubTopics"); const childTopicEls = subTopicsEl ? findChildrenByLocalName(subTopicsEl, "Topic") : []; const children = await Promise.all(childTopicEls.map((c) => walkTopic(c, false))); return { data: nodeData, children }; }; const tree = await walkTopic(rootTopicEl, true); return compactTree(tree, true); };