文章目录
解锁思维导图新姿势:将XMind转为结构化Markdown
你是否曾遇到过这些场景?
- 精心设计的XMind思维导图需要分享给只支持Markdown的协作者
- 想将思维导图发布到支持Markdown的博客平台
- 需要版本化管理思维导图内容
今天我们将深入探讨如何用Go语言构建一个强大的命令行工具,实现XMind到Markdown的无损转换。
一、认识Xmind结构
和docx等格式一样,xmind本质上来说是一个压缩包,将节点信息压缩在文件内。
text
├── Thumbnails/ # 缩略图
├── content.json # 核心内容
├── content.xml
├── manifest.json
├── metadata.json # 元数据
├── Revisions/ # 修订历史
└── resources/ # 附件资源
其中最关键的是content.json
文件,它用JSON格式存储了完整的思维导图数据结构。我们的转换工具需要精准解析这个文件。
二、核心转换流程详解
1.解压XMind文件(ZIP处理)
go
r, err := zip.OpenReader(inputPath)
defer r.Close()
for _, f := range r.File {
if f.Name == "content.json" {
// 读取文件内容
}
}
这里使用标准库archive/zip
读取压缩包,精准定位核心JSON文件。异常处理是关键点:
- 检查是否为有效ZIP文件
- 确保content.json存在
- 处理文件读取错误
2.解析JSON数据结构
我们定义了精准映射JSON的Go结构体:
go
// XMindContent represents the structure of content.json
type XMindContent []struct {
ID string `json:"id"`
Class string `json:"class"`
Title string `json:"title"`
RootTopic struct {
ID string `json:"id"`
Class string `json:"class"`
Title string `json:"title"`
Href string `json:"href"`
StructureClass string `json:"structureClass"`
Children struct {
Attached []Topic `json:"attached"`
} `json:"children"`
} `json:"rootTopic"`
}
type Topic struct {
Title string `json:"title"`
ID string `json:"id"`
Href string `json:"href"`
Position struct {
X float64 `json:"x"`
Y float64 `json:"y"`
} `json:"position"`
Children struct {
Attached []Topic `json:"attached"`
} `json:"children"`
Branch string `json:"branch"`
Markers []struct {
MarkerID string `json:"markerId"`
} `json:"markers"`
Summaries []struct {
Range string `json:"range"`
TopicID string `json:"topicId"`
} `json:"summaries"`
Image struct {
Src string `json:"src"`
Align string `json:"align"`
} `json:"image"`
AttributedTitle []struct {
Text string `json:"text"`
} `json:"attributedTitle"`
}
核心:
- 嵌套结构匹配XMind的树形数据
Attached
字段处理多分支结构- 支持标记(markers)和超链接(href)解析
3:递归转换树形结构
go
func printTopic(topic Topic, level int, output *os.File) {
// 动态计算缩进
fmt.Fprintf(output, "%s- ", strings.Repeat(" ", level))
// 处理超链接
if topic.Href != "" {
fmt.Fprintf(output, "[%s](%s)", topic.Title, topic.Href)
} else {
fmt.Fprint(output, topic.Title)
}
// 添加标记图标
if len(topic.Markers) > 0 {
fmt.Fprint(output, " [")
for i, m := range topic.Markers {
if i > 0 { fmt.Print(", ") }
fmt.Fprint(output, m.MarkerID)
}
fmt.Print("]")
}
fmt.Println()
// 递归处理子节点
for _, child := range topic.Children.Attached {
printTopic(child, level+1, output)
}
}
递归策略:
- 每个节点根据层级生成对应缩进
- 动态处理超链接和标记
- 深度优先遍历确保结构正确性
4:Markdown层级生成逻辑
采用清晰的标题层级映射:
go
# 思维导图名称 // H1
## 中心主题 // H2
### 主要分支 // H3
- 子主题1 // 无序列表
- 子子主题 // 缩进列表
这种结构完美保留了:
- 原始信息的层次关系
- 超链接资源
- 优先级标记(旗帜/星标等)
三、完整代码
go
/*
Copyright © 2025 NAME HERE <EMAIL ADDRESS>
*/
package cmd
import (
"archive/zip"
"encoding/json"
"fmt"
"io/ioutil"
"os"
"github.com/spf13/cobra"
)
// XMindContent represents the structure of content.json
type XMindContent []struct {
ID string `json:"id"`
Class string `json:"class"`
Title string `json:"title"`
RootTopic struct {
ID string `json:"id"`
Class string `json:"class"`
Title string `json:"title"`
Href string `json:"href"`
StructureClass string `json:"structureClass"`
Children struct {
Attached []Topic `json:"attached"`
} `json:"children"`
} `json:"rootTopic"`
}
type Topic struct {
Title string `json:"title"`
ID string `json:"id"`
Href string `json:"href"`
Position struct {
X float64 `json:"x"`
Y float64 `json:"y"`
} `json:"position"`
Children struct {
Attached []Topic `json:"attached"`
} `json:"children"`
Branch string `json:"branch"`
Markers []struct {
MarkerID string `json:"markerId"`
} `json:"markers"`
Summaries []struct {
Range string `json:"range"`
TopicID string `json:"topicId"`
} `json:"summaries"`
}
func generateMarkdown(sheets XMindContent, outputPath string) error {
// Create output file
outputFile, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("failed to create output file: %v", err)
}
defer outputFile.Close()
// Generate Markdown for each sheet
for _, sheet := range sheets {
// Sheet title as H1
fmt.Fprintf(outputFile, "# %s\n\n", sheet.Title)
// Root topic title as H2
fmt.Fprintf(outputFile, "## %s\n", sheet.RootTopic.Title)
if sheet.RootTopic.Href != "" {
fmt.Fprintf(outputFile, "[%s](%s)\n", sheet.RootTopic.Title, sheet.RootTopic.Href)
}
fmt.Fprintln(outputFile)
// First level topics as H3
for _, topic := range sheet.RootTopic.Children.Attached {
fmt.Fprintf(outputFile, "### %s\n", topic.Title)
if topic.Href != "" {
fmt.Fprintf(outputFile, "[%s](%s)\n", topic.Title, topic.Href)
}
// Print markers if present
if len(topic.Markers) > 0 {
fmt.Fprint(outputFile, "Markers: ")
for i, marker := range topic.Markers {
if i > 0 {
fmt.Fprint(outputFile, ", ")
}
fmt.Fprint(outputFile, marker.MarkerID)
}
fmt.Fprintln(outputFile)
}
// Deeper levels as lists
for _, child := range topic.Children.Attached {
printTopic(child, 0, outputFile)
}
fmt.Fprintln(outputFile) // Add extra space between topics
}
}
return nil
}
func printTopic(topic Topic, level int, output *os.File) {
// Print topic title with indentation
fmt.Fprintf(output, "%s- ", getIndent(level))
// Handle title with or without href
if topic.Href != "" {
fmt.Fprintf(output, "[%s](%s)", topic.Title, topic.Href)
} else {
fmt.Fprint(output, topic.Title)
}
// Show markers if present
if len(topic.Markers) > 0 {
fmt.Fprint(output, " [")
for i, marker := range topic.Markers {
if i > 0 {
fmt.Fprint(output, ", ")
}
fmt.Fprint(output, marker.MarkerID)
}
fmt.Fprint(output, "]")
}
fmt.Fprintln(output)
// Recursively print subtopics
for _, child := range topic.Children.Attached {
printTopic(child, level+1, output)
}
}
func getIndent(level int) string {
indent := ""
for i := 0; i < level; i++ {
indent += " "
}
return indent
}
func Convert(inputPath, outputPath string) error {
// 1. Unzip XMind file
r, err := zip.OpenReader(inputPath)
if err != nil {
return fmt.Errorf("failed to open XMind file: %v", err)
}
defer r.Close()
// 2. Read content.json
var content []byte
for _, f := range r.File {
if f.Name == "content.json" {
rc, err := f.Open()
if err != nil {
return fmt.Errorf("failed to open content.json: %v", err)
}
defer rc.Close()
content, err = ioutil.ReadAll(rc)
if err != nil {
return fmt.Errorf("failed to read content.json: %v", err)
}
break
}
}
if content == nil {
return fmt.Errorf("content.json not found in XMind file")
}
// 3. Parse content.json
var xmindContent XMindContent
err = json.Unmarshal(content, &xmindContent)
if err != nil {
return fmt.Errorf("failed to parse JSON: %v", err)
}
// 4. Generate Markdown
return generateMarkdown(xmindContent, outputPath)
}
// xmind2mdCmd represents the xmind2md command
var xmind2mdCmd = &cobra.Command{
Use: "xmind2md",
Short: "Convert XMind to Markdown",
Long: `Transform XMind mind maps to Markdown format`,
Run: func(cmd *cobra.Command, args []string) {
if len(args) == 0 {
fmt.Println("Please provide an input XMind file")
return
}
inputFile := args[0]
outputFile, _ := cmd.Flags().GetString("output")
if outputFile == "" {
// 去除.xmind后缀
if len(inputFile) > 6 && inputFile[len(inputFile)-6:] == ".xmind" {
outputFile = inputFile[:len(inputFile)-6] + ".md"
} else {
outputFile = inputFile + ".md"
}
}
fmt.Printf("Converting %s to %s\n", inputFile, outputFile)
err := Convert(inputFile, outputFile)
if err != nil {
fmt.Printf("Error: %v\n", err)
} else {
fmt.Printf("Successfully converted to %s\n", outputFile)
}
},
}
func init() {
xmind2mdCmd.Flags().StringP("output", "o", "", "output file")
rootCmd.AddCommand(xmind2mdCmd)
}