Add native task metadata, working-day calendar settings, dependency scheduling and rich note editing. Support complete-sheet PNG export with resolution and transparency controls, preserve XMind extensions and update browser regressions.
669 lines
25 KiB
TypeScript
669 lines
25 KiB
TypeScript
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<string, unknown>;
|
||
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<string, MindLayoutType> = {
|
||
"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<string, Data>();
|
||
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<string>();
|
||
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<string, string>();
|
||
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<string>();
|
||
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<ImportedMindMap> {
|
||
const warnings = new Set(result.warnings);
|
||
const cache = new Map<string, { url: string; bytes: Uint8Array }>();
|
||
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<ImportedMindMap> {
|
||
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 文件"
|
||
);
|
||
}
|