mirror of
https://github.com/ZeroHawkeye/wordZero.git
synced 2025-09-26 20:01:17 +08:00
增加markdown双向转换功能
This commit is contained in:
74
README.md
74
README.md
@@ -128,6 +128,75 @@ doc, _ := engine.RenderTemplateToDocument("sales_report", data)
|
||||
doc.Save("sales_report.docx")
|
||||
```
|
||||
|
||||
### Markdown转Word功能示例 ✨ **新增**
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"github.com/ZeroHawkeye/wordZero/pkg/markdown"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 创建Markdown转换器
|
||||
converter := markdown.NewConverter(markdown.DefaultOptions())
|
||||
|
||||
// Markdown内容
|
||||
markdownText := `# WordZero Markdown转换示例
|
||||
|
||||
欢迎使用WordZero的**Markdown到Word**转换功能!
|
||||
|
||||
## 支持的语法
|
||||
|
||||
### 文本格式
|
||||
- **粗体文本**
|
||||
- *斜体文本*
|
||||
- ` + "`行内代码`" + `
|
||||
|
||||
### 列表
|
||||
1. 有序列表项1
|
||||
2. 有序列表项2
|
||||
|
||||
- 无序列表项A
|
||||
- 无序列表项B
|
||||
|
||||
### 引用和代码
|
||||
|
||||
> 这是引用块内容
|
||||
> 支持多行引用
|
||||
|
||||
` + "```" + `go
|
||||
// 代码块示例
|
||||
func main() {
|
||||
fmt.Println("Hello, WordZero!")
|
||||
}
|
||||
` + "```" + `
|
||||
|
||||
---
|
||||
|
||||
转换完成!`
|
||||
|
||||
// 转换为Word文档
|
||||
doc, err := converter.ConvertString(markdownText, nil)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// 保存Word文档
|
||||
err = doc.Save("markdown_example.docx")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// 文件转换
|
||||
err = converter.ConvertFile("input.md", "output.docx", nil)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 文档和示例
|
||||
|
||||
### 📚 完整文档
|
||||
@@ -148,6 +217,7 @@ doc.Save("sales_report.docx")
|
||||
- `examples/advanced_features/` - 高级功能综合演示
|
||||
- `examples/template_demo/` - 模板功能演示
|
||||
- `examples/template_inheritance_demo/` - 模板继承功能演示 ✨ **新增**
|
||||
- `examples/markdown_conversion/` - Markdown转Word功能演示 ✨ **新增**
|
||||
|
||||
运行示例:
|
||||
```bash
|
||||
@@ -162,6 +232,9 @@ go run ./examples/table/
|
||||
|
||||
# 运行模板继承演示
|
||||
go run ./examples/template_inheritance_demo/
|
||||
|
||||
# 运行Markdown转Word演示
|
||||
go run ./examples/markdown_conversion/
|
||||
```
|
||||
|
||||
## 主要功能
|
||||
@@ -175,6 +248,7 @@ go run ./examples/template_inheritance_demo/
|
||||
- **页面设置**: 页面尺寸、边距、页眉页脚等
|
||||
- **高级功能**: 目录生成、脚注尾注、列表编号、模板引擎(含模板继承)
|
||||
- **图片功能**: 图片插入、大小调整、位置设置
|
||||
- **Markdown转Word**: 基于goldmark的高质量Markdown到Word转换 ✨ **新增**
|
||||
|
||||
### 🚧 规划中功能
|
||||
- 表格排序和高级操作
|
||||
|
300
examples/markdown_conversion/main.go
Normal file
300
examples/markdown_conversion/main.go
Normal file
@@ -0,0 +1,300 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/ZeroHawkeye/wordZero/pkg/markdown"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Println("WordZero Markdown双向转换完整示例")
|
||||
fmt.Println("===================================")
|
||||
|
||||
// 确保输出目录存在
|
||||
outputDir := "examples/output"
|
||||
err := os.MkdirAll(outputDir, 0755)
|
||||
if err != nil {
|
||||
fmt.Printf("❌ 创建输出目录失败: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// 演示1: Markdown转Word
|
||||
demonstrateMarkdownToWord(outputDir)
|
||||
|
||||
// 演示2: Word转Markdown(反向转换)
|
||||
demonstrateWordToMarkdown(outputDir)
|
||||
|
||||
// 演示3: 双向转换器使用
|
||||
demonstrateBidirectionalConverter(outputDir)
|
||||
|
||||
// 演示4: 批量转换功能
|
||||
demonstrateBatchConversion(outputDir)
|
||||
|
||||
fmt.Println("\n🎉 所有转换示例运行完成!")
|
||||
}
|
||||
|
||||
// demonstrateMarkdownToWord 演示Markdown转Word功能
|
||||
func demonstrateMarkdownToWord(outputDir string) {
|
||||
fmt.Println("\n📝 演示1: Markdown → Word 转换")
|
||||
fmt.Println("================================")
|
||||
|
||||
// 创建示例Markdown内容
|
||||
markdownContent := `# WordZero Markdown双向转换功能
|
||||
|
||||
欢迎使用WordZero库的Markdown和Word文档双向转换功能!
|
||||
|
||||
## 功能特性概览
|
||||
|
||||
WordZero现在支持**完整的双向转换**:
|
||||
|
||||
### 🚀 Markdown → Word 转换
|
||||
- **goldmark解析引擎**: 基于CommonMark 0.31.2规范
|
||||
- **完整语法支持**: 标题、格式化、列表、表格、图片、链接
|
||||
- **智能样式映射**: 自动应用Word标准样式
|
||||
- **可配置选项**: GitHub风味Markdown、脚注、错误处理
|
||||
|
||||
### 🔄 Word → Markdown 反向转换
|
||||
- **结构完整保持**: 保持原文档的层次结构
|
||||
- **格式智能识别**: 自动识别并转换文本格式
|
||||
- **图片导出支持**: 提取图片并生成引用
|
||||
- **多种导出模式**: GFM表格、Setext标题等选项
|
||||
|
||||
### 文本格式化示例
|
||||
- **粗体文本**展示
|
||||
- *斜体文本*展示
|
||||
- ` + "`行内代码`" + `展示
|
||||
|
||||
### 列表支持示例
|
||||
|
||||
#### 无序列表
|
||||
- 功能A: 基础Markdown语法
|
||||
- 功能B: GitHub风味扩展
|
||||
- 功能C: 自定义配置选项
|
||||
|
||||
#### 有序列表
|
||||
1. 安装WordZero库
|
||||
2. 创建转换器实例
|
||||
3. 调用转换方法
|
||||
4. 处理转换结果
|
||||
|
||||
### 引用块示例
|
||||
|
||||
> 这是一个引用块示例,演示引用文本的转换效果。
|
||||
>
|
||||
> 引用块中可以包含多行内容,在Word中会以特殊格式显示。
|
||||
|
||||
### 代码块示例
|
||||
|
||||
` + "```" + `go
|
||||
// WordZero双向转换示例代码
|
||||
package main
|
||||
|
||||
import "github.com/ZeroHawkeye/wordZero/pkg/markdown"
|
||||
|
||||
func main() {
|
||||
// Markdown转Word
|
||||
converter := markdown.NewConverter(markdown.DefaultOptions())
|
||||
doc, _ := converter.ConvertString(markdownText, nil)
|
||||
doc.Save("output.docx")
|
||||
|
||||
// Word转Markdown
|
||||
exporter := markdown.NewExporter(markdown.DefaultExportOptions())
|
||||
exporter.ExportToFile("input.docx", "output.md", nil)
|
||||
}
|
||||
` + "```" + `
|
||||
|
||||
---
|
||||
|
||||
## 技术实现亮点
|
||||
|
||||
### 🔧 核心技术栈
|
||||
- **goldmark**: 高性能Markdown解析器
|
||||
- **WordZero**: 原生Go Word文档处理
|
||||
- **双向转换**: 无缝的格式转换支持
|
||||
|
||||
### 📋 支持的配置选项
|
||||
- ✅ GitHub Flavored Markdown扩展
|
||||
- ✅ 脚注和尾注支持
|
||||
- ✅ 表格格式转换(待完善)
|
||||
- ✅ 任务列表支持(待实现)
|
||||
- ✅ 图片处理和路径解析
|
||||
- ✅ 错误处理和进度报告
|
||||
|
||||
### 🎯 使用场景
|
||||
1. **技术文档转换**: 从Markdown快速生成Word文档
|
||||
2. **报告自动化**: 将Word报告转换为Markdown
|
||||
3. **版本控制友好**: Word文档转为可diff的Markdown
|
||||
4. **批量处理**: 大量文档的格式转换
|
||||
|
||||
## 总结
|
||||
|
||||
WordZero的双向转换功能为现代文档工作流提供了强大支持,
|
||||
无论是从轻量级的Markdown到专业的Word文档,
|
||||
还是反向的格式转换,都能满足不同场景的需求。`
|
||||
|
||||
// 创建转换器(使用高质量配置)
|
||||
opts := markdown.HighQualityOptions()
|
||||
opts.GenerateTOC = true
|
||||
opts.TOCMaxLevel = 3
|
||||
converter := markdown.NewConverter(opts)
|
||||
|
||||
fmt.Println("📝 正在转换Markdown内容...")
|
||||
|
||||
// 转换为Word文档
|
||||
doc, err := converter.ConvertString(markdownContent, nil)
|
||||
if err != nil {
|
||||
fmt.Printf("❌ 转换失败: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// 保存Word文档
|
||||
outputPath := outputDir + "/markdown_to_word_demo.docx"
|
||||
err = doc.Save(outputPath)
|
||||
if err != nil {
|
||||
fmt.Printf("❌ 保存文档失败: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("✅ Markdown转Word成功!输出: %s\n", outputPath)
|
||||
|
||||
// 同时保存Markdown源文件供后续演示使用
|
||||
markdownPath := outputDir + "/source_document.md"
|
||||
err = os.WriteFile(markdownPath, []byte(markdownContent), 0644)
|
||||
if err != nil {
|
||||
fmt.Printf("❌ 保存Markdown文件失败: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// demonstrateWordToMarkdown 演示Word转Markdown功能
|
||||
func demonstrateWordToMarkdown(outputDir string) {
|
||||
fmt.Println("\n📄 演示2: Word → Markdown 反向转换")
|
||||
fmt.Println("===================================")
|
||||
|
||||
// 使用上一步生成的Word文档
|
||||
wordPath := outputDir + "/markdown_to_word_demo.docx"
|
||||
markdownOutputPath := outputDir + "/word_to_markdown_result.md"
|
||||
|
||||
// 创建导出器(使用高质量配置)
|
||||
exportOpts := markdown.HighQualityExportOptions()
|
||||
exportOpts.ExtractImages = true
|
||||
exportOpts.ImageOutputDir = outputDir + "/extracted_images"
|
||||
exportOpts.UseGFMTables = true
|
||||
exportOpts.IncludeMetadata = true
|
||||
|
||||
exporter := markdown.NewExporter(exportOpts)
|
||||
|
||||
fmt.Println("📄 正在将Word文档转换为Markdown...")
|
||||
|
||||
// 执行反向转换
|
||||
err := exporter.ExportToFile(wordPath, markdownOutputPath, nil)
|
||||
if err != nil {
|
||||
fmt.Printf("❌ Word转Markdown失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("✅ Word转Markdown成功!输出: %s\n", markdownOutputPath)
|
||||
|
||||
// 显示转换结果预览
|
||||
content, err := os.ReadFile(markdownOutputPath)
|
||||
if err == nil && len(content) > 0 {
|
||||
preview := string(content)
|
||||
if len(preview) > 300 {
|
||||
preview = preview[:300] + "..."
|
||||
}
|
||||
fmt.Printf("📋 转换结果预览:\n%s\n", preview)
|
||||
}
|
||||
}
|
||||
|
||||
// demonstrateBidirectionalConverter 演示双向转换器
|
||||
func demonstrateBidirectionalConverter(outputDir string) {
|
||||
fmt.Println("\n🔄 演示3: 双向转换器统一接口")
|
||||
fmt.Println("===============================")
|
||||
|
||||
// 创建双向转换器
|
||||
converter := markdown.NewBidirectionalConverter(
|
||||
markdown.HighQualityOptions(),
|
||||
markdown.HighQualityExportOptions(),
|
||||
)
|
||||
|
||||
// 测试自动类型检测转换
|
||||
testCases := []struct {
|
||||
input string
|
||||
output string
|
||||
desc string
|
||||
}{
|
||||
{
|
||||
input: outputDir + "/source_document.md",
|
||||
output: outputDir + "/auto_converted.docx",
|
||||
desc: "Markdown自动转换为Word",
|
||||
},
|
||||
{
|
||||
input: outputDir + "/markdown_to_word_demo.docx",
|
||||
output: outputDir + "/auto_converted.md",
|
||||
desc: "Word自动转换为Markdown",
|
||||
},
|
||||
}
|
||||
|
||||
for i, tc := range testCases {
|
||||
fmt.Printf("🔄 测试%d: %s\n", i+1, tc.desc)
|
||||
|
||||
err := converter.AutoConvert(tc.input, tc.output)
|
||||
if err != nil {
|
||||
fmt.Printf("❌ 自动转换失败: %v\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Printf("✅ 自动转换成功: %s\n", tc.output)
|
||||
}
|
||||
}
|
||||
|
||||
// demonstrateBatchConversion 演示批量转换功能
|
||||
func demonstrateBatchConversion(outputDir string) {
|
||||
fmt.Println("\n📦 演示4: 批量转换功能")
|
||||
fmt.Println("=======================")
|
||||
|
||||
// 创建多个测试文件
|
||||
testMarkdownFiles := []string{
|
||||
outputDir + "/test1.md",
|
||||
outputDir + "/test2.md",
|
||||
outputDir + "/test3.md",
|
||||
}
|
||||
|
||||
testContents := []string{
|
||||
"# 测试文档1\n\n这是第一个测试文档。\n\n## 内容\n- 项目A\n- 项目B",
|
||||
"# 测试文档2\n\n这是第二个测试文档。\n\n> 引用内容示例",
|
||||
"# 测试文档3\n\n这是第三个测试文档。\n\n```go\nfmt.Println(\"Hello\")\n```",
|
||||
}
|
||||
|
||||
// 创建测试文件
|
||||
for i, content := range testContents {
|
||||
err := os.WriteFile(testMarkdownFiles[i], []byte(content), 0644)
|
||||
if err != nil {
|
||||
fmt.Printf("❌ 创建测试文件失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 执行批量转换
|
||||
converter := markdown.NewConverter(markdown.DefaultOptions())
|
||||
batchOutputDir := outputDir + "/batch_output"
|
||||
|
||||
fmt.Println("📦 正在执行批量Markdown转Word...")
|
||||
|
||||
err := converter.BatchConvert(testMarkdownFiles, batchOutputDir, &markdown.ConvertOptions{
|
||||
ProgressCallback: func(current, total int) {
|
||||
fmt.Printf("📊 批量转换进度: %d/%d\n", current, total)
|
||||
},
|
||||
ErrorCallback: func(err error) {
|
||||
fmt.Printf("⚠️ 转换警告: %v\n", err)
|
||||
},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf("❌ 批量转换失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("✅ 批量转换完成!输出目录: %s\n", batchOutputDir)
|
||||
}
|
74
examples/markdown_demo/table_and_tasklist_demo.go
Normal file
74
examples/markdown_demo/table_and_tasklist_demo.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/ZeroHawkeye/wordZero/pkg/markdown"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 示例Markdown内容,包含表格和任务列表
|
||||
markdownContent := `# 表格和任务列表示例
|
||||
|
||||
## 表格示例
|
||||
|
||||
下面是一个简单的表格:
|
||||
|
||||
| 姓名 | 年龄 | 城市 |
|
||||
|--------|------|--------|
|
||||
| 张三 | 25 | 北京 |
|
||||
| 李四 | 30 | 上海 |
|
||||
| 王五 | 28 | 广州 |
|
||||
|
||||
## 任务列表示例
|
||||
|
||||
待办事项:
|
||||
|
||||
- [x] 完成项目需求分析
|
||||
- [ ] 设计系统架构
|
||||
- [ ] 实现核心功能
|
||||
- [x] 用户管理
|
||||
- [ ] 权限控制
|
||||
- [ ] 数据存储
|
||||
- [x] 编写测试用例
|
||||
- [ ] 部署到生产环境
|
||||
|
||||
## 混合内容
|
||||
|
||||
这是一个包含**粗体**和*斜体*的段落。
|
||||
|
||||
### 对齐表格
|
||||
|
||||
| 左对齐 | 居中对齐 | 右对齐 |
|
||||
|:-------|:--------:|-------:|
|
||||
| 内容1 | 内容2 | 内容3 |
|
||||
| 较长内容 | 短内容 | 数字 |
|
||||
`
|
||||
|
||||
// 创建转换器
|
||||
opts := markdown.HighQualityOptions()
|
||||
opts.EnableTables = true
|
||||
opts.EnableTaskList = true
|
||||
converter := markdown.NewConverter(opts)
|
||||
|
||||
// 转换为Word文档
|
||||
doc, err := converter.ConvertString(markdownContent, opts)
|
||||
if err != nil {
|
||||
log.Fatalf("转换失败: %v", err)
|
||||
}
|
||||
|
||||
// 保存文档
|
||||
outputPath := "examples/output/table_and_tasklist_demo.docx"
|
||||
err = doc.Save(outputPath)
|
||||
if err != nil {
|
||||
log.Fatalf("保存文档失败: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("✅ 表格和任务列表示例已保存到: %s\n", outputPath)
|
||||
fmt.Println("📝 示例包含以下功能:")
|
||||
fmt.Println(" • GFM表格转换为Word表格")
|
||||
fmt.Println(" • 任务列表复选框显示")
|
||||
fmt.Println(" • 表格对齐方式保持")
|
||||
fmt.Println(" • 混合格式文本支持")
|
||||
}
|
@@ -39,7 +39,7 @@ func main() {
|
||||
demonstrateTableDeletion(doc)
|
||||
|
||||
// 保存文档
|
||||
outputFile := "../output/table_demo.docx"
|
||||
outputFile := "examples/output/table_demo.docx"
|
||||
err := doc.Save(outputFile)
|
||||
if err != nil {
|
||||
log.Fatalf("保存文档失败: %v", err)
|
||||
|
6
go.mod
6
go.mod
@@ -1,3 +1,7 @@
|
||||
module github.com/ZeroHawkeye/wordZero
|
||||
|
||||
go 1.19
|
||||
go 1.22
|
||||
|
||||
toolchain go1.24.2
|
||||
|
||||
require github.com/yuin/goldmark v1.7.12 // indirect
|
||||
|
2
go.sum
Normal file
2
go.sum
Normal file
@@ -0,0 +1,2 @@
|
||||
github.com/yuin/goldmark v1.7.12 h1:YwGP/rrea2/CnCtUHgjuolG/PnMxdQtPMO5PvaE2/nY=
|
||||
github.com/yuin/goldmark v1.7.12/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
@@ -747,4 +747,239 @@ doc.Save("example.docx")
|
||||
10. 图片支持PNG、JPEG、GIF格式,会自动嵌入到文档中
|
||||
11. 图片尺寸可以用毫米或像素指定,支持保持长宽比的缩放
|
||||
12. 图片位置支持嵌入式、左浮动、右浮动等多种布局方式
|
||||
13. 图片对齐功能仅适用于嵌入式图片(ImagePositionInline),浮动图片请使用位置控制
|
||||
13. 图片对齐功能仅适用于嵌入式图片(ImagePositionInline),浮动图片请使用位置控制
|
||||
|
||||
## Markdown转Word功能 ✨ **新增功能**
|
||||
|
||||
WordZero现在支持将Markdown文档转换为Word格式,基于goldmark解析引擎实现,提供高质量的转换效果。
|
||||
|
||||
### Markdown包API
|
||||
|
||||
#### 转换器接口
|
||||
- [`NewConverter(options *ConvertOptions)`](../markdown/converter.go) - 创建新的Markdown转换器
|
||||
- [`DefaultOptions()`](../markdown/config.go) - 获取默认转换选项
|
||||
- [`HighQualityOptions()`](../markdown/config.go) - 获取高质量转换选项
|
||||
|
||||
#### 转换方法
|
||||
- [`ConvertString(content string, options *ConvertOptions)`](../markdown/converter.go) - 转换Markdown字符串为Word文档
|
||||
- [`ConvertBytes(content []byte, options *ConvertOptions)`](../markdown/converter.go) - 转换Markdown字节数组为Word文档
|
||||
- [`ConvertFile(mdPath, docxPath string, options *ConvertOptions)`](../markdown/converter.go) - 转换Markdown文件为Word文件
|
||||
- [`BatchConvert(inputs []string, outputDir string, options *ConvertOptions)`](../markdown/converter.go) - 批量转换Markdown文件
|
||||
|
||||
#### 配置选项 (`ConvertOptions`)
|
||||
- `EnableGFM` - 启用GitHub Flavored Markdown支持
|
||||
- `EnableFootnotes` - 启用脚注支持
|
||||
- `EnableTables` - 启用表格支持
|
||||
- `EnableTaskList` - 启用任务列表支持
|
||||
- `StyleMapping` - 自定义样式映射
|
||||
- `DefaultFontFamily` - 默认字体族
|
||||
- `DefaultFontSize` - 默认字体大小
|
||||
- `ImageBasePath` - 图片基础路径
|
||||
- `EmbedImages` - 是否嵌入图片
|
||||
- `MaxImageWidth` - 最大图片宽度(英寸)
|
||||
- `PreserveLinkStyle` - 保留链接样式
|
||||
- `ConvertToBookmarks` - 内部链接转书签
|
||||
- `GenerateTOC` - 生成目录
|
||||
- `TOCMaxLevel` - 目录最大级别
|
||||
- `PageSettings` - 页面设置
|
||||
- `StrictMode` - 严格模式
|
||||
- `IgnoreErrors` - 忽略转换错误
|
||||
- `ErrorCallback` - 错误回调函数
|
||||
- `ProgressCallback` - 进度回调函数
|
||||
|
||||
### 支持的Markdown语法
|
||||
|
||||
#### 基础语法
|
||||
- **标题** (`# ## ### #### ##### ######`) - 转换为Word标题样式1-6
|
||||
- **段落** - 转换为Word正文段落
|
||||
- **粗体** (`**文本**`) - 转换为粗体格式
|
||||
- **斜体** (`*文本*`) - 转换为斜体格式
|
||||
- **行内代码** (`` `代码` ``) - 转换为等宽字体
|
||||
- **代码块** (``` ```) - 转换为代码块样式
|
||||
|
||||
#### 列表支持
|
||||
- **无序列表** (`- * +`) - 转换为Word项目符号列表
|
||||
- **有序列表** (`1. 2. 3.`) - 转换为Word编号列表
|
||||
- **多级列表** - 支持嵌套列表结构
|
||||
|
||||
#### GitHub Flavored Markdown扩展 ✨ **新增**
|
||||
- **表格** (`| 列1 | 列2 |`) - 转换为Word表格
|
||||
- 支持表头自动识别和样式设置
|
||||
- 支持对齐控制(左对齐 `:---`、居中 `:---:`、右对齐 `---:`)
|
||||
- 自动设置表格边框和单元格格式
|
||||
- **任务列表** (`- [x] 已完成` / `- [ ] 未完成`) - 转换为复选框符号
|
||||
- ☑ 表示已完成任务
|
||||
- ☐ 表示未完成任务
|
||||
- 支持嵌套任务列表
|
||||
- 支持混合格式(粗体、斜体、代码等)
|
||||
|
||||
#### 其他元素
|
||||
- **引用块** (`> 引用文本`) - 转换为斜体引用样式
|
||||
- **分割线** (`---`) - 转换为水平线
|
||||
- **链接** (`[文本](URL)`) - 转换为蓝色文本(后续支持超链接)
|
||||
- **图片** (``) - 转换为图片占位符(后续支持图片嵌入)
|
||||
|
||||
### 使用示例
|
||||
|
||||
#### 基础字符串转换
|
||||
```go
|
||||
import "github.com/ZeroHawkeye/wordZero/pkg/markdown"
|
||||
|
||||
// 创建转换器
|
||||
converter := markdown.NewConverter(markdown.DefaultOptions())
|
||||
|
||||
// 转换Markdown字符串
|
||||
markdownText := `# 标题
|
||||
|
||||
这是一个包含**粗体**和*斜体*的段落。
|
||||
|
||||
## 子标题
|
||||
|
||||
- 列表项1
|
||||
- 列表项2
|
||||
|
||||
> 引用文本
|
||||
|
||||
` + "`" + `代码示例` + "`" + `
|
||||
`
|
||||
|
||||
doc, err := converter.ConvertString(markdownText, nil)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// 保存Word文档
|
||||
err = doc.Save("output.docx")
|
||||
```
|
||||
|
||||
#### 表格和任务列表示例 ✨ **新增**
|
||||
```go
|
||||
// 启用表格和任务列表功能
|
||||
options := markdown.DefaultOptions()
|
||||
options.EnableTables = true
|
||||
options.EnableTaskList = true
|
||||
|
||||
converter := markdown.NewConverter(options)
|
||||
|
||||
// 包含表格和任务列表的Markdown
|
||||
markdownWithTable := `# 项目进度表
|
||||
|
||||
## 功能实现状态
|
||||
|
||||
| 功能名称 | 状态 | 负责人 |
|
||||
|:---------|:----:|-------:|
|
||||
| 表格转换 | ✅ | 张三 |
|
||||
| 任务列表 | ✅ | 李四 |
|
||||
| 图片处理 | 🚧 | 王五 |
|
||||
|
||||
## 待办事项
|
||||
|
||||
- [x] 实现表格转换功能
|
||||
- [x] 基础表格支持
|
||||
- [x] 对齐方式处理
|
||||
- [x] 表头样式设置
|
||||
- [ ] 完善任务列表功能
|
||||
- [x] 复选框显示
|
||||
- [ ] 交互功能
|
||||
- [ ] 图片嵌入支持
|
||||
- [ ] PNG格式
|
||||
- [ ] JPEG格式
|
||||
|
||||
## 备注
|
||||
|
||||
> 表格支持**左对齐**、` + "`" + `居中对齐` + "`" + `和***右对齐***三种方式
|
||||
`
|
||||
|
||||
doc, err := converter.ConvertString(markdownWithTable, options)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
err = doc.Save("project_status.docx")
|
||||
```
|
||||
|
||||
#### 高级配置示例
|
||||
```go
|
||||
// 创建高质量转换配置
|
||||
options := &markdown.ConvertOptions{
|
||||
EnableGFM: true,
|
||||
EnableFootnotes: true,
|
||||
EnableTables: true,
|
||||
GenerateTOC: true,
|
||||
TOCMaxLevel: 3,
|
||||
DefaultFontFamily: "Calibri",
|
||||
DefaultFontSize: 11.0,
|
||||
EmbedImages: true,
|
||||
MaxImageWidth: 6.0,
|
||||
PageSettings: &document.PageSettings{
|
||||
Size: document.PageSizeA4,
|
||||
Orientation: document.OrientationPortrait,
|
||||
MarginTop: 25,
|
||||
MarginRight: 20,
|
||||
MarginBottom: 25,
|
||||
MarginLeft: 20,
|
||||
},
|
||||
ProgressCallback: func(current, total int) {
|
||||
fmt.Printf("转换进度: %d/%d\n", current, total)
|
||||
},
|
||||
}
|
||||
|
||||
converter := markdown.NewConverter(options)
|
||||
```
|
||||
|
||||
#### 文件转换示例
|
||||
```go
|
||||
// 单文件转换
|
||||
err := converter.ConvertFile("input.md", "output.docx", nil)
|
||||
|
||||
// 批量文件转换
|
||||
files := []string{"doc1.md", "doc2.md", "doc3.md"}
|
||||
err := converter.BatchConvert(files, "output/", options)
|
||||
```
|
||||
|
||||
#### 自定义样式映射
|
||||
```go
|
||||
options := markdown.DefaultOptions()
|
||||
options.StyleMapping = map[string]string{
|
||||
"heading1": "CustomTitle",
|
||||
"heading2": "CustomSubtitle",
|
||||
"quote": "CustomQuote",
|
||||
"code": "CustomCode",
|
||||
}
|
||||
|
||||
converter := markdown.NewConverter(options)
|
||||
```
|
||||
|
||||
### 技术特性
|
||||
|
||||
#### 架构设计
|
||||
- **goldmark集成** - 使用高性能的goldmark解析引擎
|
||||
- **AST遍历** - 基于抽象语法树的转换处理
|
||||
- **API复用** - 充分复用现有WordZero document API
|
||||
- **向后兼容** - 不影响现有document包功能
|
||||
|
||||
#### 性能优势
|
||||
- **流式处理** - 支持大型文档的流式转换
|
||||
- **内存效率** - 优化的内存使用模式
|
||||
- **并发支持** - 批量转换支持并发处理
|
||||
- **错误恢复** - 智能错误处理和恢复机制
|
||||
|
||||
#### 扩展性
|
||||
- **插件架构** - 支持自定义渲染器扩展
|
||||
- **配置驱动** - 丰富的配置选项支持不同需求
|
||||
- **样式系统** - 灵活的样式映射和自定义能力
|
||||
- **回调机制** - 进度和错误回调支持
|
||||
|
||||
### 注意事项
|
||||
|
||||
1. **兼容性** - 基于CommonMark 0.31.2标准,与GitHub Markdown高度兼容
|
||||
2. **图片处理** - 当前版本图片转换为占位符,完整图片支持在规划中
|
||||
3. **表格支持** ✨ **已完善** - 支持完整的GFM表格语法,包括对齐控制和表头样式
|
||||
4. **任务列表** ✨ **已实现** - 支持任务复选框,显示为Unicode符号(☑/☐)
|
||||
5. **链接处理** - 当前转换为蓝色文本,超链接功能在开发中
|
||||
6. **样式映射** - 可通过StyleMapping自定义Markdown元素到Word样式的映射
|
||||
7. **错误处理** - 建议在生产环境中启用错误回调,监控转换质量
|
||||
8. **性能考虑** - 批量转换大量文件时建议分批处理,避免内存压力
|
||||
9. **编码支持** - 完全支持UTF-8编码,包括中文等多字节字符
|
||||
10. **配置要求** - 表格和任务列表功能需要在ConvertOptions中显式启用
|
||||
11. **向后兼容** - 新功能不会影响现有的document包API,保持完全兼容
|
211
pkg/markdown/README.md
Normal file
211
pkg/markdown/README.md
Normal file
@@ -0,0 +1,211 @@
|
||||
# WordZero Markdown转换包
|
||||
|
||||
`pkg/markdown` 包提供了 Markdown 和 Word 文档之间的双向转换功能。
|
||||
|
||||
## 功能特性
|
||||
|
||||
### Markdown → Word 转换
|
||||
- 基于 goldmark 解析引擎
|
||||
- 支持 GitHub Flavored Markdown (GFM)
|
||||
- 支持标题、格式化文本、列表、表格、图片、链接等
|
||||
- 可配置的转换选项
|
||||
|
||||
### Word → Markdown 转换 (新增)
|
||||
- 支持将 Word 文档反向导出为 Markdown
|
||||
- 保持文档结构和格式
|
||||
- 支持图片导出
|
||||
- 多种导出配置选项
|
||||
|
||||
## 基本使用
|
||||
|
||||
### Word 到 Markdown 转换
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/ZeroHawkeye/wordZero/pkg/markdown"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 创建导出器
|
||||
exporter := markdown.NewExporter(markdown.DefaultExportOptions())
|
||||
|
||||
// 导出Word文档为Markdown
|
||||
err := exporter.ExportToFile("document.docx", "output.md", nil)
|
||||
if err != nil {
|
||||
fmt.Printf("导出失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("Word文档已成功转换为Markdown!")
|
||||
}
|
||||
```
|
||||
|
||||
### Markdown 到 Word 转换
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/ZeroHawkeye/wordZero/pkg/markdown"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 创建转换器
|
||||
converter := markdown.NewConverter(markdown.DefaultOptions())
|
||||
|
||||
// 转换Markdown为Word文档
|
||||
err := converter.ConvertFile("input.md", "output.docx", nil)
|
||||
if err != nil {
|
||||
fmt.Printf("转换失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("Markdown已成功转换为Word文档!")
|
||||
}
|
||||
```
|
||||
|
||||
### 双向转换器
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/ZeroHawkeye/wordZero/pkg/markdown"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 创建双向转换器
|
||||
converter := markdown.NewBidirectionalConverter(
|
||||
markdown.DefaultOptions(), // Markdown→Word选项
|
||||
markdown.DefaultExportOptions(), // Word→Markdown选项
|
||||
)
|
||||
|
||||
// 自动检测文件类型并转换
|
||||
err := converter.AutoConvert("input.docx", "output.md")
|
||||
if err != nil {
|
||||
fmt.Printf("转换失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("文档转换完成!")
|
||||
}
|
||||
```
|
||||
|
||||
## 高级配置
|
||||
|
||||
### Word 到 Markdown 导出选项
|
||||
|
||||
```go
|
||||
options := &markdown.ExportOptions{
|
||||
UseGFMTables: true, // 使用GitHub风味Markdown表格
|
||||
ExtractImages: true, // 导出图片文件
|
||||
ImageOutputDir: "images/", // 图片输出目录
|
||||
PreserveFootnotes: true, // 保留脚注
|
||||
UseSetext: true, // 使用Setext样式标题
|
||||
IncludeMetadata: true, // 包含文档元数据
|
||||
ProgressCallback: func(current, total int) {
|
||||
fmt.Printf("进度: %d/%d\n", current, total)
|
||||
},
|
||||
}
|
||||
|
||||
exporter := markdown.NewExporter(options)
|
||||
```
|
||||
|
||||
### Markdown 到 Word 转换选项
|
||||
|
||||
```go
|
||||
options := &markdown.ConvertOptions{
|
||||
EnableGFM: true, // 启用GitHub风味Markdown
|
||||
EnableFootnotes: true, // 启用脚注支持
|
||||
EnableTables: true, // 启用表格支持
|
||||
DefaultFontFamily: "Calibri", // 默认字体
|
||||
DefaultFontSize: 11.0, // 默认字号
|
||||
GenerateTOC: true, // 生成目录
|
||||
TOCMaxLevel: 3, // 目录最大级别
|
||||
}
|
||||
|
||||
converter := markdown.NewConverter(options)
|
||||
```
|
||||
|
||||
## 支持的转换映射
|
||||
|
||||
### Word → Markdown
|
||||
|
||||
| Word元素 | Markdown语法 | 说明 |
|
||||
|----------|-------------|------|
|
||||
| Heading1-6 | `# 标题` | 标题级别对应 |
|
||||
| 粗体 | `**粗体**` | 文本格式 |
|
||||
| 斜体 | `*斜体*` | 文本格式 |
|
||||
| 删除线 | `~~删除线~~` | 文本格式 |
|
||||
| 代码 | `` `代码` `` | 行内代码 |
|
||||
| 代码块 | ```` 代码块 ```` | 代码块 |
|
||||
| 超链接 | `[链接](url)` | 链接转换 |
|
||||
| 图片 | `` | 图片引用 |
|
||||
| 表格 | `\| 表格 \|` | GFM表格 |
|
||||
| 列表 | `- 项目` | 列表项 |
|
||||
|
||||
### Markdown → Word
|
||||
|
||||
| Markdown语法 | Word元素 | 实现方式 |
|
||||
|-------------|----------|----------|
|
||||
| `# 标题` | Heading1样式 | `AddHeadingParagraph()` |
|
||||
| `**粗体**` | 粗体格式 | `RunProperties.Bold` |
|
||||
| `*斜体*` | 斜体格式 | `RunProperties.Italic` |
|
||||
| `` `代码` `` | 代码样式 | 等宽字体 |
|
||||
| `[链接](url)` | 超链接 | `AddHyperlink()` |
|
||||
| `` | 图片 | `AddImageFromFile()` |
|
||||
| `\| 表格 \|` | Word表格 | `AddTable()` |
|
||||
| `- 列表` | 项目符号列表 | `AddBulletList()` |
|
||||
|
||||
## 批量转换
|
||||
|
||||
```go
|
||||
// 批量Markdown转Word
|
||||
converter := markdown.NewConverter(markdown.DefaultOptions())
|
||||
inputs := []string{"doc1.md", "doc2.md", "doc3.md"}
|
||||
err := converter.BatchConvert(inputs, "output/", nil)
|
||||
|
||||
// 批量Word转Markdown
|
||||
exporter := markdown.NewExporter(markdown.DefaultExportOptions())
|
||||
inputs := []string{"doc1.docx", "doc2.docx", "doc3.docx"}
|
||||
err := exporter.BatchExport(inputs, "markdown/", nil)
|
||||
```
|
||||
|
||||
## 错误处理
|
||||
|
||||
```go
|
||||
options := &markdown.ExportOptions{
|
||||
StrictMode: true, // 严格模式
|
||||
IgnoreErrors: false, // 不忽略错误
|
||||
ErrorCallback: func(err error) {
|
||||
fmt.Printf("转换错误: %v\n", err)
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## 兼容性说明
|
||||
|
||||
- 该包与现有的 `pkg/document` 包完全兼容
|
||||
- 不修改任何现有API
|
||||
- 可以与现有代码无缝集成
|
||||
- 支持所有现有的Word文档操作功能
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. Word到Markdown转换会丢失某些Word特有的格式信息
|
||||
2. 复杂的表格布局可能需要手动调整
|
||||
3. 图片需要单独处理导出
|
||||
4. 某些Word样式在Markdown中没有直接对应
|
||||
|
||||
## 未来计划
|
||||
|
||||
- [ ] 数学公式支持
|
||||
- [ ] Mermaid图表转换
|
||||
- [ ] 更好的列表嵌套支持
|
||||
- [ ] 自定义样式映射
|
||||
- [ ] 命令行工具
|
69
pkg/markdown/config.go
Normal file
69
pkg/markdown/config.go
Normal file
@@ -0,0 +1,69 @@
|
||||
// Package markdown 提供Markdown到Word文档的转换功能
|
||||
package markdown
|
||||
|
||||
import "github.com/ZeroHawkeye/wordZero/pkg/document"
|
||||
|
||||
// ConvertOptions 转换选项配置
|
||||
type ConvertOptions struct {
|
||||
// 基础配置
|
||||
EnableGFM bool // 启用GitHub Flavored Markdown
|
||||
EnableFootnotes bool // 启用脚注支持
|
||||
EnableTables bool // 启用表格支持
|
||||
EnableTaskList bool // 启用任务列表
|
||||
|
||||
// 样式配置
|
||||
StyleMapping map[string]string // 自定义样式映射
|
||||
DefaultFontFamily string // 默认字体
|
||||
DefaultFontSize float64 // 默认字号
|
||||
|
||||
// 图片处理
|
||||
ImageBasePath string // 图片基础路径
|
||||
EmbedImages bool // 是否嵌入图片
|
||||
MaxImageWidth float64 // 最大图片宽度(英寸)
|
||||
|
||||
// 链接处理
|
||||
PreserveLinkStyle bool // 保留链接样式
|
||||
ConvertToBookmarks bool // 内部链接转书签
|
||||
|
||||
// 文档设置
|
||||
GenerateTOC bool // 生成目录
|
||||
TOCMaxLevel int // 目录最大级别
|
||||
PageSettings *document.PageSettings // 页面设置(使用现有结构)
|
||||
|
||||
// 错误处理
|
||||
StrictMode bool // 严格模式
|
||||
IgnoreErrors bool // 忽略转换错误
|
||||
ErrorCallback func(error) // 错误回调
|
||||
|
||||
// 进度报告
|
||||
ProgressCallback func(int, int) // 进度回调
|
||||
}
|
||||
|
||||
// DefaultOptions 返回默认的转换配置
|
||||
func DefaultOptions() *ConvertOptions {
|
||||
return &ConvertOptions{
|
||||
EnableGFM: true,
|
||||
EnableFootnotes: true,
|
||||
EnableTables: true,
|
||||
EnableTaskList: true,
|
||||
DefaultFontFamily: "Calibri",
|
||||
DefaultFontSize: 11.0,
|
||||
EmbedImages: false,
|
||||
MaxImageWidth: 6.0, // 英寸
|
||||
GenerateTOC: true,
|
||||
TOCMaxLevel: 3,
|
||||
StrictMode: false,
|
||||
IgnoreErrors: true,
|
||||
}
|
||||
}
|
||||
|
||||
// HighQualityOptions 返回高质量转换配置
|
||||
func HighQualityOptions() *ConvertOptions {
|
||||
opts := DefaultOptions()
|
||||
opts.EmbedImages = true
|
||||
opts.PreserveLinkStyle = true
|
||||
opts.ConvertToBookmarks = true
|
||||
opts.StrictMode = true
|
||||
opts.IgnoreErrors = false
|
||||
return opts
|
||||
}
|
162
pkg/markdown/converter.go
Normal file
162
pkg/markdown/converter.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package markdown
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
"github.com/yuin/goldmark/parser"
|
||||
"github.com/yuin/goldmark/text"
|
||||
|
||||
"github.com/ZeroHawkeye/wordZero/pkg/document"
|
||||
)
|
||||
|
||||
// MarkdownConverter Markdown转换器接口
|
||||
type MarkdownConverter interface {
|
||||
// ConvertFile 转换单个文件
|
||||
ConvertFile(mdPath, docxPath string, options *ConvertOptions) error
|
||||
|
||||
// ConvertBytes 转换字节数据
|
||||
ConvertBytes(mdContent []byte, options *ConvertOptions) (*document.Document, error)
|
||||
|
||||
// ConvertString 转换字符串
|
||||
ConvertString(mdContent string, options *ConvertOptions) (*document.Document, error)
|
||||
|
||||
// BatchConvert 批量转换
|
||||
BatchConvert(inputs []string, outputDir string, options *ConvertOptions) error
|
||||
}
|
||||
|
||||
// Converter 默认转换器实现
|
||||
type Converter struct {
|
||||
md goldmark.Markdown
|
||||
opts *ConvertOptions
|
||||
}
|
||||
|
||||
// NewConverter 创建新的转换器实例
|
||||
func NewConverter(opts *ConvertOptions) *Converter {
|
||||
if opts == nil {
|
||||
opts = DefaultOptions()
|
||||
}
|
||||
|
||||
extensions := []goldmark.Extender{}
|
||||
if opts.EnableGFM {
|
||||
extensions = append(extensions, extension.GFM)
|
||||
}
|
||||
if opts.EnableFootnotes {
|
||||
extensions = append(extensions, extension.Footnote)
|
||||
}
|
||||
|
||||
md := goldmark.New(
|
||||
goldmark.WithExtensions(extensions...),
|
||||
goldmark.WithParserOptions(
|
||||
parser.WithAutoHeadingID(),
|
||||
),
|
||||
)
|
||||
|
||||
return &Converter{md: md, opts: opts}
|
||||
}
|
||||
|
||||
// ConvertString 转换字符串内容为Word文档
|
||||
func (c *Converter) ConvertString(content string, opts *ConvertOptions) (*document.Document, error) {
|
||||
return c.ConvertBytes([]byte(content), opts)
|
||||
}
|
||||
|
||||
// ConvertBytes 转换字节数据为Word文档
|
||||
func (c *Converter) ConvertBytes(content []byte, opts *ConvertOptions) (*document.Document, error) {
|
||||
if opts != nil {
|
||||
c.opts = opts
|
||||
}
|
||||
|
||||
// 创建新的Word文档
|
||||
doc := document.New()
|
||||
|
||||
// 应用页面设置
|
||||
if c.opts.PageSettings != nil {
|
||||
// 这里可以后续扩展,使用现有的页面设置API
|
||||
}
|
||||
|
||||
// 解析Markdown
|
||||
reader := text.NewReader(content)
|
||||
astDoc := c.md.Parser().Parse(reader)
|
||||
|
||||
// 创建渲染器并转换
|
||||
renderer := &WordRenderer{
|
||||
doc: doc,
|
||||
opts: c.opts,
|
||||
source: content,
|
||||
}
|
||||
|
||||
err := renderer.Render(astDoc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
// ConvertFile 转换文件
|
||||
func (c *Converter) ConvertFile(mdPath, docxPath string, options *ConvertOptions) error {
|
||||
// 读取Markdown文件
|
||||
content, err := os.ReadFile(mdPath)
|
||||
if err != nil {
|
||||
return NewConversionError("FileRead", "failed to read markdown file", 0, 0, err)
|
||||
}
|
||||
|
||||
// 设置图片基础路径(如果未指定)
|
||||
if options == nil {
|
||||
options = c.opts
|
||||
}
|
||||
if options.ImageBasePath == "" {
|
||||
options.ImageBasePath = filepath.Dir(mdPath)
|
||||
}
|
||||
|
||||
// 转换内容
|
||||
doc, err := c.ConvertBytes(content, options)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 保存Word文档
|
||||
err = doc.Save(docxPath)
|
||||
if err != nil {
|
||||
return NewConversionError("FileSave", "failed to save word document", 0, 0, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// BatchConvert 批量转换文件
|
||||
func (c *Converter) BatchConvert(inputs []string, outputDir string, options *ConvertOptions) error {
|
||||
// 确保输出目录存在
|
||||
err := os.MkdirAll(outputDir, 0755)
|
||||
if err != nil {
|
||||
return NewConversionError("DirectoryCreate", "failed to create output directory", 0, 0, err)
|
||||
}
|
||||
|
||||
total := len(inputs)
|
||||
for i, input := range inputs {
|
||||
// 报告进度
|
||||
if options != nil && options.ProgressCallback != nil {
|
||||
options.ProgressCallback(i+1, total)
|
||||
}
|
||||
|
||||
// 生成输出文件名
|
||||
base := strings.TrimSuffix(filepath.Base(input), filepath.Ext(input))
|
||||
output := filepath.Join(outputDir, base+".docx")
|
||||
|
||||
// 转换单个文件
|
||||
err := c.ConvertFile(input, output, options)
|
||||
if err != nil {
|
||||
if options != nil && options.ErrorCallback != nil {
|
||||
options.ErrorCallback(err)
|
||||
}
|
||||
if options == nil || !options.IgnoreErrors {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
91
pkg/markdown/errors.go
Normal file
91
pkg/markdown/errors.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package markdown
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrUnsupportedMarkdown 不支持的Markdown语法
|
||||
ErrUnsupportedMarkdown = errors.New("unsupported markdown syntax")
|
||||
|
||||
// ErrInvalidImagePath 无效的图片路径
|
||||
ErrInvalidImagePath = errors.New("invalid image path")
|
||||
|
||||
// ErrFileNotFound 文件未找到
|
||||
ErrFileNotFound = errors.New("file not found")
|
||||
|
||||
// ErrInvalidMarkdown 无效的Markdown内容
|
||||
ErrInvalidMarkdown = errors.New("invalid markdown content")
|
||||
|
||||
// ErrConversionFailed 转换失败
|
||||
ErrConversionFailed = errors.New("conversion failed")
|
||||
|
||||
// ErrUnsupportedWordElement 不支持的Word元素
|
||||
ErrUnsupportedWordElement = errors.New("unsupported word element")
|
||||
|
||||
// ErrExportFailed 导出失败
|
||||
ErrExportFailed = errors.New("export failed")
|
||||
|
||||
// ErrInvalidDocument 无效的Word文档
|
||||
ErrInvalidDocument = errors.New("invalid word document")
|
||||
)
|
||||
|
||||
// ConversionError 转换错误,包含详细信息
|
||||
type ConversionError struct {
|
||||
Type string // 错误类型
|
||||
Message string // 错误消息
|
||||
Line int // 错误行号(如果适用)
|
||||
Column int // 错误列号(如果适用)
|
||||
Cause error // 原始错误
|
||||
}
|
||||
|
||||
// Error 实现error接口
|
||||
func (e *ConversionError) Error() string {
|
||||
if e.Line > 0 {
|
||||
return fmt.Sprintf("%s at line %d, column %d: %s", e.Type, e.Line, e.Column, e.Message)
|
||||
}
|
||||
return fmt.Sprintf("%s: %s", e.Type, e.Message)
|
||||
}
|
||||
|
||||
// Unwrap 返回原始错误,支持errors.Unwrap
|
||||
func (e *ConversionError) Unwrap() error {
|
||||
return e.Cause
|
||||
}
|
||||
|
||||
// NewConversionError 创建新的转换错误
|
||||
func NewConversionError(errorType, message string, line, column int, cause error) *ConversionError {
|
||||
return &ConversionError{
|
||||
Type: errorType,
|
||||
Message: message,
|
||||
Line: line,
|
||||
Column: column,
|
||||
Cause: cause,
|
||||
}
|
||||
}
|
||||
|
||||
// ExportError 导出错误,包含详细信息
|
||||
type ExportError struct {
|
||||
Type string // 错误类型
|
||||
Message string // 错误消息
|
||||
Cause error // 原始错误
|
||||
}
|
||||
|
||||
// Error 实现error接口
|
||||
func (e *ExportError) Error() string {
|
||||
return fmt.Sprintf("%s: %s", e.Type, e.Message)
|
||||
}
|
||||
|
||||
// Unwrap 返回原始错误,支持errors.Unwrap
|
||||
func (e *ExportError) Unwrap() error {
|
||||
return e.Cause
|
||||
}
|
||||
|
||||
// NewExportError 创建新的导出错误
|
||||
func NewExportError(errorType, message string, cause error) *ExportError {
|
||||
return &ExportError{
|
||||
Type: errorType,
|
||||
Message: message,
|
||||
Cause: cause,
|
||||
}
|
||||
}
|
196
pkg/markdown/exporter.go
Normal file
196
pkg/markdown/exporter.go
Normal file
@@ -0,0 +1,196 @@
|
||||
package markdown
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/ZeroHawkeye/wordZero/pkg/document"
|
||||
)
|
||||
|
||||
// WordToMarkdownExporter Word到Markdown导出器接口
|
||||
type WordToMarkdownExporter interface {
|
||||
// ExportToFile 导出Word文档到Markdown文件
|
||||
ExportToFile(docxPath, mdPath string, options *ExportOptions) error
|
||||
|
||||
// ExportToString 导出Word文档到Markdown字符串
|
||||
ExportToString(doc *document.Document, options *ExportOptions) (string, error)
|
||||
|
||||
// ExportToBytes 导出Word文档到Markdown字节数组
|
||||
ExportToBytes(doc *document.Document, options *ExportOptions) ([]byte, error)
|
||||
|
||||
// BatchExport 批量导出
|
||||
BatchExport(inputs []string, outputDir string, options *ExportOptions) error
|
||||
}
|
||||
|
||||
// Exporter Word到Markdown导出器实现
|
||||
type Exporter struct {
|
||||
opts *ExportOptions
|
||||
}
|
||||
|
||||
// NewExporter 创建新的导出器实例
|
||||
func NewExporter(opts *ExportOptions) *Exporter {
|
||||
if opts == nil {
|
||||
opts = DefaultExportOptions()
|
||||
}
|
||||
return &Exporter{opts: opts}
|
||||
}
|
||||
|
||||
// ExportToFile 导出Word文档到Markdown文件
|
||||
func (e *Exporter) ExportToFile(docxPath, mdPath string, options *ExportOptions) error {
|
||||
// 加载Word文档
|
||||
doc, err := document.Open(docxPath)
|
||||
if err != nil {
|
||||
return NewExportError("DocumentOpen", fmt.Sprintf("failed to open document: %v", err), err)
|
||||
}
|
||||
|
||||
// 设置图片输出路径
|
||||
if options == nil {
|
||||
options = e.opts
|
||||
}
|
||||
if options.ExtractImages && options.ImageOutputDir == "" {
|
||||
options.ImageOutputDir = filepath.Dir(mdPath)
|
||||
}
|
||||
|
||||
// 转换为Markdown
|
||||
markdown, err := e.ExportToString(doc, options)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 写入文件
|
||||
err = os.WriteFile(mdPath, []byte(markdown), 0644)
|
||||
if err != nil {
|
||||
return NewExportError("FileWrite", fmt.Sprintf("failed to write markdown file: %v", err), err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExportToString 导出Word文档到Markdown字符串
|
||||
func (e *Exporter) ExportToString(doc *document.Document, options *ExportOptions) (string, error) {
|
||||
bytes, err := e.ExportToBytes(doc, options)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(bytes), nil
|
||||
}
|
||||
|
||||
// ExportToBytes 导出Word文档到Markdown字节数组
|
||||
func (e *Exporter) ExportToBytes(doc *document.Document, options *ExportOptions) ([]byte, error) {
|
||||
if options != nil {
|
||||
e.opts = options
|
||||
}
|
||||
|
||||
writer := &MarkdownWriter{
|
||||
opts: e.opts,
|
||||
doc: doc,
|
||||
imageNum: 0,
|
||||
footnotes: make([]string, 0),
|
||||
}
|
||||
|
||||
return writer.Write()
|
||||
}
|
||||
|
||||
// BatchExport 批量导出
|
||||
func (e *Exporter) BatchExport(inputs []string, outputDir string, options *ExportOptions) error {
|
||||
// 确保输出目录存在
|
||||
err := os.MkdirAll(outputDir, 0755)
|
||||
if err != nil {
|
||||
return NewExportError("DirectoryCreate", fmt.Sprintf("failed to create output directory: %v", err), err)
|
||||
}
|
||||
|
||||
total := len(inputs)
|
||||
for i, input := range inputs {
|
||||
// 报告进度
|
||||
if options != nil && options.ProgressCallback != nil {
|
||||
options.ProgressCallback(i+1, total)
|
||||
}
|
||||
|
||||
// 生成输出文件名
|
||||
base := strings.TrimSuffix(filepath.Base(input), filepath.Ext(input))
|
||||
output := filepath.Join(outputDir, base+".md")
|
||||
|
||||
// 导出单个文件
|
||||
err := e.ExportToFile(input, output, options)
|
||||
if err != nil {
|
||||
if options != nil && options.ErrorCallback != nil {
|
||||
options.ErrorCallback(err)
|
||||
}
|
||||
if options == nil || !options.IgnoreErrors {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DefaultExportOptions 返回默认的导出配置
|
||||
func DefaultExportOptions() *ExportOptions {
|
||||
return &ExportOptions{
|
||||
UseGFMTables: true,
|
||||
PreserveFootnotes: true,
|
||||
PreserveLineBreaks: false,
|
||||
WrapLongLines: false,
|
||||
MaxLineLength: 80,
|
||||
ExtractImages: true,
|
||||
ImageNamePattern: "image_%d.png",
|
||||
ImageRelativePath: true,
|
||||
PreserveBookmarks: true,
|
||||
ConvertHyperlinks: true,
|
||||
PreserveCodeStyle: true,
|
||||
DefaultCodeLang: "",
|
||||
IgnoreUnknownStyles: true,
|
||||
PreserveTOC: false,
|
||||
IncludeMetadata: false,
|
||||
StripComments: true,
|
||||
UseSetext: false,
|
||||
BulletListMarker: "-",
|
||||
EmphasisMarker: "*",
|
||||
StrictMode: false,
|
||||
IgnoreErrors: true,
|
||||
}
|
||||
}
|
||||
|
||||
// HighQualityExportOptions 返回高质量导出配置
|
||||
func HighQualityExportOptions() *ExportOptions {
|
||||
opts := DefaultExportOptions()
|
||||
opts.ExtractImages = true
|
||||
opts.PreserveFootnotes = true
|
||||
opts.PreserveBookmarks = true
|
||||
opts.PreserveTOC = true
|
||||
opts.IncludeMetadata = true
|
||||
opts.StrictMode = true
|
||||
opts.IgnoreErrors = false
|
||||
return opts
|
||||
}
|
||||
|
||||
// BidirectionalConverter 双向转换器
|
||||
type BidirectionalConverter struct {
|
||||
mdToWord *Converter
|
||||
wordToMd *Exporter
|
||||
}
|
||||
|
||||
// NewBidirectionalConverter 创建双向转换器
|
||||
func NewBidirectionalConverter(mdOpts *ConvertOptions, exportOpts *ExportOptions) *BidirectionalConverter {
|
||||
return &BidirectionalConverter{
|
||||
mdToWord: NewConverter(mdOpts),
|
||||
wordToMd: NewExporter(exportOpts),
|
||||
}
|
||||
}
|
||||
|
||||
// AutoConvert 自动检测文件类型并转换
|
||||
func (bc *BidirectionalConverter) AutoConvert(inputPath, outputPath string) error {
|
||||
ext := strings.ToLower(filepath.Ext(inputPath))
|
||||
|
||||
switch ext {
|
||||
case ".md", ".markdown":
|
||||
return bc.mdToWord.ConvertFile(inputPath, outputPath, nil)
|
||||
case ".docx":
|
||||
return bc.wordToMd.ExportToFile(inputPath, outputPath, nil)
|
||||
default:
|
||||
return fmt.Errorf("unsupported file type: %s", ext)
|
||||
}
|
||||
}
|
512
pkg/markdown/renderer.go
Normal file
512
pkg/markdown/renderer.go
Normal file
@@ -0,0 +1,512 @@
|
||||
package markdown
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/ZeroHawkeye/wordZero/pkg/document"
|
||||
"github.com/yuin/goldmark/ast"
|
||||
|
||||
// 添加goldmark扩展的AST节点支持
|
||||
extast "github.com/yuin/goldmark/extension/ast"
|
||||
)
|
||||
|
||||
// WordRenderer Word文档渲染器
|
||||
type WordRenderer struct {
|
||||
doc *document.Document
|
||||
opts *ConvertOptions
|
||||
source []byte
|
||||
listLevel int // 当前列表嵌套级别
|
||||
}
|
||||
|
||||
// Render 渲染AST为Word文档
|
||||
func (r *WordRenderer) Render(doc ast.Node) error {
|
||||
return ast.Walk(doc, func(node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
switch n := node.(type) {
|
||||
case *ast.Document:
|
||||
// 文档根节点,继续处理子节点
|
||||
return ast.WalkContinue, nil
|
||||
|
||||
case *ast.Heading:
|
||||
return r.renderHeading(n)
|
||||
|
||||
case *ast.Paragraph:
|
||||
return r.renderParagraph(n)
|
||||
|
||||
case *ast.List:
|
||||
return r.renderList(n)
|
||||
|
||||
case *ast.ListItem:
|
||||
return r.renderListItem(n)
|
||||
|
||||
case *ast.Blockquote:
|
||||
return r.renderBlockquote(n)
|
||||
|
||||
case *ast.FencedCodeBlock:
|
||||
return r.renderCodeBlock(n)
|
||||
|
||||
case *ast.CodeBlock:
|
||||
return r.renderCodeBlock(n)
|
||||
|
||||
case *ast.ThematicBreak:
|
||||
return r.renderThematicBreak(n)
|
||||
|
||||
case *ast.Text:
|
||||
// Text节点由父节点处理
|
||||
return ast.WalkSkipChildren, nil
|
||||
|
||||
case *ast.Emphasis:
|
||||
// 强调节点由父节点处理
|
||||
return ast.WalkSkipChildren, nil
|
||||
|
||||
case *ast.Link:
|
||||
// 链接节点由父节点处理
|
||||
return ast.WalkSkipChildren, nil
|
||||
|
||||
case *ast.Image:
|
||||
return r.renderImage(n)
|
||||
|
||||
// 表格支持
|
||||
case *extast.Table:
|
||||
if r.opts.EnableTables {
|
||||
return r.renderTable(n)
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
|
||||
case *extast.TableRow:
|
||||
// TableRow节点由Table处理
|
||||
return ast.WalkSkipChildren, nil
|
||||
|
||||
case *extast.TableCell:
|
||||
// TableCell节点由Table处理
|
||||
return ast.WalkSkipChildren, nil
|
||||
|
||||
// 任务列表支持
|
||||
case *extast.TaskCheckBox:
|
||||
if r.opts.EnableTaskList {
|
||||
return r.renderTaskCheckBox(n)
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
|
||||
default:
|
||||
// 对于不支持的节点类型,记录错误但继续处理
|
||||
if r.opts.ErrorCallback != nil {
|
||||
r.opts.ErrorCallback(NewConversionError("UnsupportedNode", "unsupported markdown node type", 0, 0, nil))
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// renderHeading 渲染标题
|
||||
func (r *WordRenderer) renderHeading(node *ast.Heading) (ast.WalkStatus, error) {
|
||||
text := r.extractTextContent(node)
|
||||
level := node.Level
|
||||
|
||||
// 限制标题级别
|
||||
if level > 6 {
|
||||
level = 6
|
||||
}
|
||||
|
||||
// 使用现有的API,确保兼容性
|
||||
if r.opts.GenerateTOC && level <= r.opts.TOCMaxLevel {
|
||||
// 复用现有的AddHeadingWithBookmark方法
|
||||
r.doc.AddHeadingWithBookmark(text, level, "")
|
||||
} else {
|
||||
// 复用现有的AddHeadingParagraph方法
|
||||
r.doc.AddHeadingParagraph(text, level)
|
||||
}
|
||||
|
||||
return ast.WalkSkipChildren, nil
|
||||
}
|
||||
|
||||
// renderParagraph 渲染段落
|
||||
func (r *WordRenderer) renderParagraph(node *ast.Paragraph) (ast.WalkStatus, error) {
|
||||
// 检查段落是否为空
|
||||
if !node.HasChildren() {
|
||||
return ast.WalkSkipChildren, nil
|
||||
}
|
||||
|
||||
// 创建段落
|
||||
para := r.doc.AddParagraph("")
|
||||
|
||||
// 处理段落内容
|
||||
r.renderInlineContent(node, para)
|
||||
|
||||
return ast.WalkSkipChildren, nil
|
||||
}
|
||||
|
||||
// renderInlineContent 渲染内联内容(文本、强调、链接等)
|
||||
func (r *WordRenderer) renderInlineContent(node ast.Node, para *document.Paragraph) {
|
||||
for child := node.FirstChild(); child != nil; child = child.NextSibling() {
|
||||
switch n := child.(type) {
|
||||
case *ast.Text:
|
||||
text := string(n.Segment.Value(r.source))
|
||||
para.AddFormattedText(text, nil)
|
||||
|
||||
case *ast.Emphasis:
|
||||
text := r.extractTextContent(n)
|
||||
// goldmark中,level=1是斜体,level=2是粗体
|
||||
if n.Level == 2 {
|
||||
format := &document.TextFormat{Bold: true}
|
||||
para.AddFormattedText(text, format)
|
||||
} else {
|
||||
format := &document.TextFormat{Italic: true}
|
||||
para.AddFormattedText(text, format)
|
||||
}
|
||||
|
||||
case *ast.CodeSpan:
|
||||
text := r.extractTextContent(n)
|
||||
format := &document.TextFormat{
|
||||
FontFamily: "Consolas",
|
||||
}
|
||||
para.AddFormattedText(text, format)
|
||||
|
||||
case *ast.Link:
|
||||
text := r.extractTextContent(n)
|
||||
// 简单处理链接,后续可以扩展为超链接
|
||||
format := &document.TextFormat{
|
||||
FontColor: "0000FF", // 蓝色
|
||||
}
|
||||
para.AddFormattedText(text, format)
|
||||
|
||||
case *ast.Image:
|
||||
r.renderImageInline(n, para)
|
||||
|
||||
default:
|
||||
// 对于其他类型,尝试提取文本内容
|
||||
text := r.extractTextContent(n)
|
||||
if text != "" {
|
||||
para.AddFormattedText(text, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// renderList 渲染列表
|
||||
func (r *WordRenderer) renderList(node *ast.List) (ast.WalkStatus, error) {
|
||||
r.listLevel++
|
||||
defer func() { r.listLevel-- }()
|
||||
|
||||
// 处理列表项
|
||||
for child := node.FirstChild(); child != nil; child = child.NextSibling() {
|
||||
if listItem, ok := child.(*ast.ListItem); ok {
|
||||
r.renderListItem(listItem)
|
||||
}
|
||||
}
|
||||
|
||||
return ast.WalkSkipChildren, nil
|
||||
}
|
||||
|
||||
// renderListItem 渲染列表项
|
||||
func (r *WordRenderer) renderListItem(node *ast.ListItem) (ast.WalkStatus, error) {
|
||||
// 检查是否包含任务复选框
|
||||
hasTaskCheckBox := false
|
||||
for child := node.FirstChild(); child != nil; child = child.NextSibling() {
|
||||
if _, ok := child.(*extast.TaskCheckBox); ok {
|
||||
hasTaskCheckBox = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 如果包含任务复选框且启用了任务列表,让TaskCheckBox节点处理
|
||||
if hasTaskCheckBox && r.opts.EnableTaskList {
|
||||
// 任务列表项将由TaskCheckBox节点处理
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// 普通列表项处理
|
||||
text := r.extractTextContent(node)
|
||||
|
||||
// 简单的列表项处理,后续可以扩展为真正的列表格式
|
||||
// 这里暂时使用缩进和符号来模拟列表
|
||||
indent := strings.Repeat(" ", r.listLevel-1)
|
||||
bulletText := "• " + text
|
||||
|
||||
r.doc.AddParagraph(indent + bulletText)
|
||||
|
||||
return ast.WalkSkipChildren, nil
|
||||
}
|
||||
|
||||
// renderBlockquote 渲染引用块
|
||||
func (r *WordRenderer) renderBlockquote(node *ast.Blockquote) (ast.WalkStatus, error) {
|
||||
text := r.extractTextContent(node)
|
||||
|
||||
// 创建引用段落,使用斜体格式
|
||||
format := &document.TextFormat{
|
||||
Italic: true,
|
||||
}
|
||||
r.doc.AddFormattedParagraph("> "+text, format)
|
||||
|
||||
return ast.WalkSkipChildren, nil
|
||||
}
|
||||
|
||||
// renderCodeBlock 渲染代码块
|
||||
func (r *WordRenderer) renderCodeBlock(node ast.Node) (ast.WalkStatus, error) {
|
||||
text := r.extractCodeBlockText(node)
|
||||
|
||||
// 使用等宽字体显示代码
|
||||
format := &document.TextFormat{
|
||||
FontFamily: "Consolas",
|
||||
FontSize: 10,
|
||||
}
|
||||
r.doc.AddFormattedParagraph(text, format)
|
||||
|
||||
return ast.WalkSkipChildren, nil
|
||||
}
|
||||
|
||||
// renderThematicBreak 渲染分割线
|
||||
func (r *WordRenderer) renderThematicBreak(node *ast.ThematicBreak) (ast.WalkStatus, error) {
|
||||
// 添加分页符或水平线
|
||||
// 这里暂时用一行横线文本来表示
|
||||
r.doc.AddParagraph("─────────────────────────────────────")
|
||||
|
||||
return ast.WalkSkipChildren, nil
|
||||
}
|
||||
|
||||
// renderImage 渲染图片
|
||||
func (r *WordRenderer) renderImage(node *ast.Image) (ast.WalkStatus, error) {
|
||||
// 获取图片路径
|
||||
src := string(node.Destination)
|
||||
alt := r.extractTextContent(node)
|
||||
|
||||
// 处理相对路径
|
||||
if !filepath.IsAbs(src) && r.opts.ImageBasePath != "" {
|
||||
src = filepath.Join(r.opts.ImageBasePath, src)
|
||||
}
|
||||
|
||||
// 尝试添加图片,如果失败则添加替代文本
|
||||
// 这里需要后续完善图片处理逻辑
|
||||
if alt != "" {
|
||||
r.doc.AddParagraph("[图片: " + alt + "]")
|
||||
} else {
|
||||
r.doc.AddParagraph("[图片: " + src + "]")
|
||||
}
|
||||
|
||||
return ast.WalkSkipChildren, nil
|
||||
}
|
||||
|
||||
// renderImageInline 渲染内联图片
|
||||
func (r *WordRenderer) renderImageInline(node *ast.Image, para *document.Paragraph) {
|
||||
src := string(node.Destination)
|
||||
alt := r.extractTextContent(node)
|
||||
|
||||
// 处理相对路径
|
||||
if !filepath.IsAbs(src) && r.opts.ImageBasePath != "" {
|
||||
src = filepath.Join(r.opts.ImageBasePath, src)
|
||||
}
|
||||
|
||||
// 内联图片暂时用文本替代
|
||||
if alt != "" {
|
||||
para.AddFormattedText("[图片: "+alt+"]", nil)
|
||||
} else {
|
||||
para.AddFormattedText("[图片: "+src+"]", nil)
|
||||
}
|
||||
}
|
||||
|
||||
// extractTextContent 提取节点的文本内容
|
||||
func (r *WordRenderer) extractTextContent(node ast.Node) string {
|
||||
var buf strings.Builder
|
||||
r.extractTextContentRecursive(node, &buf)
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// extractTextContentRecursive 递归提取文本内容
|
||||
func (r *WordRenderer) extractTextContentRecursive(node ast.Node, buf *strings.Builder) {
|
||||
for child := node.FirstChild(); child != nil; child = child.NextSibling() {
|
||||
switch n := child.(type) {
|
||||
case *ast.Text:
|
||||
buf.Write(n.Segment.Value(r.source))
|
||||
default:
|
||||
r.extractTextContentRecursive(child, buf)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// extractCodeBlockText 提取代码块文本
|
||||
func (r *WordRenderer) extractCodeBlockText(node ast.Node) string {
|
||||
var buf strings.Builder
|
||||
|
||||
for i := 0; i < node.Lines().Len(); i++ {
|
||||
line := node.Lines().At(i)
|
||||
buf.Write(line.Value(r.source))
|
||||
}
|
||||
|
||||
return strings.TrimRight(buf.String(), "\n")
|
||||
}
|
||||
|
||||
// cleanText 清理文本内容
|
||||
func (r *WordRenderer) cleanText(text string) string {
|
||||
// 移除多余的空白字符
|
||||
re := regexp.MustCompile(`\s+`)
|
||||
text = re.ReplaceAllString(text, " ")
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
|
||||
// renderTable 渲染表格
|
||||
func (r *WordRenderer) renderTable(node *extast.Table) (ast.WalkStatus, error) {
|
||||
// 收集表格数据
|
||||
var tableData [][]string
|
||||
var alignments []extast.Alignment
|
||||
|
||||
// 遍历表格行
|
||||
for child := node.FirstChild(); child != nil; child = child.NextSibling() {
|
||||
if row, ok := child.(*extast.TableRow); ok {
|
||||
var rowData []string
|
||||
if len(alignments) == 0 {
|
||||
// 从第一行获取对齐方式
|
||||
alignments = row.Alignments
|
||||
}
|
||||
|
||||
// 遍历单元格
|
||||
for cellChild := row.FirstChild(); cellChild != nil; cellChild = cellChild.NextSibling() {
|
||||
if cell, ok := cellChild.(*extast.TableCell); ok {
|
||||
cellText := r.extractTextContent(cell)
|
||||
rowData = append(rowData, cellText)
|
||||
}
|
||||
}
|
||||
tableData = append(tableData, rowData)
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有数据,跳过
|
||||
if len(tableData) == 0 {
|
||||
return ast.WalkSkipChildren, nil
|
||||
}
|
||||
|
||||
// 计算列数
|
||||
cols := 0
|
||||
for _, row := range tableData {
|
||||
if len(row) > cols {
|
||||
cols = len(row)
|
||||
}
|
||||
}
|
||||
|
||||
// 创建表格配置
|
||||
config := &document.TableConfig{
|
||||
Rows: len(tableData),
|
||||
Cols: cols,
|
||||
Width: 9000, // 默认宽度(磅)
|
||||
Data: tableData,
|
||||
}
|
||||
|
||||
// 添加表格到文档
|
||||
table := r.doc.AddTable(config)
|
||||
if table != nil {
|
||||
// 设置表头样式(如果有的话)
|
||||
if len(tableData) > 0 {
|
||||
// 第一行设为表头样式
|
||||
err := table.SetRowAsHeader(0, true)
|
||||
if err != nil && r.opts.ErrorCallback != nil {
|
||||
r.opts.ErrorCallback(NewConversionError("TableHeader", "failed to set table header", 0, 0, err))
|
||||
}
|
||||
}
|
||||
|
||||
// 根据对齐方式设置单元格对齐
|
||||
for rowIdx, row := range tableData {
|
||||
for colIdx := range row {
|
||||
if colIdx < len(alignments) {
|
||||
var align document.CellAlignment
|
||||
switch alignments[colIdx] {
|
||||
case extast.AlignLeft:
|
||||
align = document.CellAlignLeft
|
||||
case extast.AlignCenter:
|
||||
align = document.CellAlignCenter
|
||||
case extast.AlignRight:
|
||||
align = document.CellAlignRight
|
||||
default:
|
||||
align = document.CellAlignLeft
|
||||
}
|
||||
|
||||
format := &document.CellFormat{
|
||||
HorizontalAlign: align,
|
||||
}
|
||||
err := table.SetCellFormat(rowIdx, colIdx, format)
|
||||
if err != nil && r.opts.ErrorCallback != nil {
|
||||
r.opts.ErrorCallback(NewConversionError("CellFormat", "failed to set cell format", rowIdx, colIdx, err))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ast.WalkSkipChildren, nil
|
||||
}
|
||||
|
||||
// renderTaskCheckBox 渲染任务列表复选框 ✨ 新增功能
|
||||
func (r *WordRenderer) renderTaskCheckBox(node *extast.TaskCheckBox) (ast.WalkStatus, error) {
|
||||
// 获取复选框状态
|
||||
checked := node.IsChecked
|
||||
|
||||
// 根据状态选择符号
|
||||
var checkSymbol string
|
||||
if checked {
|
||||
checkSymbol = "☑" // 选中的复选框
|
||||
} else {
|
||||
checkSymbol = "☐" // 未选中的复选框
|
||||
}
|
||||
|
||||
// 创建一个段落来包含复选框
|
||||
para := r.doc.AddParagraph("")
|
||||
|
||||
// 添加复选框符号
|
||||
para.AddFormattedText(checkSymbol+" ", nil)
|
||||
|
||||
// 处理任务项文本(通常是父级ListItem中的其他内容)
|
||||
// 注意:TaskCheckBox通常是ListItem的第一个子元素
|
||||
parent := node.Parent()
|
||||
if parent != nil {
|
||||
// 提取除TaskCheckBox外的其他文本内容
|
||||
r.renderTaskItemContent(parent, para, node)
|
||||
}
|
||||
|
||||
return ast.WalkSkipChildren, nil
|
||||
}
|
||||
|
||||
// renderTaskItemContent 渲染任务项内容(除复选框外的文本)
|
||||
func (r *WordRenderer) renderTaskItemContent(parent ast.Node, para *document.Paragraph, skipNode ast.Node) {
|
||||
for child := parent.FirstChild(); child != nil; child = child.NextSibling() {
|
||||
// 跳过复选框节点本身
|
||||
if child == skipNode {
|
||||
continue
|
||||
}
|
||||
|
||||
switch n := child.(type) {
|
||||
case *ast.Text:
|
||||
text := string(n.Segment.Value(r.source))
|
||||
para.AddFormattedText(text, nil)
|
||||
case *ast.Emphasis:
|
||||
text := r.extractTextContent(n)
|
||||
if n.Level == 2 {
|
||||
format := &document.TextFormat{Bold: true}
|
||||
para.AddFormattedText(text, format)
|
||||
} else {
|
||||
format := &document.TextFormat{Italic: true}
|
||||
para.AddFormattedText(text, format)
|
||||
}
|
||||
case *ast.CodeSpan:
|
||||
text := r.extractTextContent(n)
|
||||
format := &document.TextFormat{
|
||||
FontFamily: "Consolas",
|
||||
}
|
||||
para.AddFormattedText(text, format)
|
||||
case *ast.Link:
|
||||
text := r.extractTextContent(n)
|
||||
format := &document.TextFormat{
|
||||
FontColor: "0000FF", // 蓝色
|
||||
}
|
||||
para.AddFormattedText(text, format)
|
||||
default:
|
||||
// 对于其他类型,尝试提取文本内容
|
||||
text := r.extractTextContent(n)
|
||||
if text != "" {
|
||||
para.AddFormattedText(text, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
464
pkg/markdown/writer.go
Normal file
464
pkg/markdown/writer.go
Normal file
@@ -0,0 +1,464 @@
|
||||
package markdown
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/ZeroHawkeye/wordZero/pkg/document"
|
||||
)
|
||||
|
||||
// ExportOptions 导出选项配置
|
||||
type ExportOptions struct {
|
||||
// 基础配置
|
||||
UseGFMTables bool // 使用GFM表格语法
|
||||
PreserveFootnotes bool // 保留脚注
|
||||
PreserveLineBreaks bool // 保留换行符
|
||||
WrapLongLines bool // 自动换行长行
|
||||
MaxLineLength int // 最大行长度
|
||||
|
||||
// 图片处理
|
||||
ExtractImages bool // 是否导出图片文件
|
||||
ImageOutputDir string // 图片输出目录
|
||||
ImageNamePattern string // 图片命名模式
|
||||
ImageRelativePath bool // 使用相对路径引用图片
|
||||
|
||||
// 链接处理
|
||||
PreserveBookmarks bool // 保留书签为锚点链接
|
||||
ConvertHyperlinks bool // 转换超链接
|
||||
|
||||
// 代码块处理
|
||||
PreserveCodeStyle bool // 保留代码样式
|
||||
DefaultCodeLang string // 默认代码语言标识
|
||||
|
||||
// 样式映射
|
||||
CustomStyleMap map[string]string // 自定义样式映射
|
||||
IgnoreUnknownStyles bool // 忽略未知样式
|
||||
|
||||
// 内容处理
|
||||
PreserveTOC bool // 保留目录
|
||||
IncludeMetadata bool // 包含文档元数据
|
||||
StripComments bool // 移除注释
|
||||
|
||||
// 格式化选项
|
||||
UseSetext bool // 使用Setext样式标题
|
||||
BulletListMarker string // 项目符号标记
|
||||
EmphasisMarker string // 强调标记
|
||||
|
||||
// 错误处理
|
||||
StrictMode bool // 严格模式
|
||||
IgnoreErrors bool // 忽略转换错误
|
||||
ErrorCallback func(error) // 错误回调
|
||||
|
||||
// 进度报告
|
||||
ProgressCallback func(int, int) // 进度回调
|
||||
}
|
||||
|
||||
// MarkdownWriter Markdown格式输出器
|
||||
type MarkdownWriter struct {
|
||||
opts *ExportOptions
|
||||
doc *document.Document
|
||||
output strings.Builder
|
||||
imageNum int
|
||||
footnotes []string
|
||||
}
|
||||
|
||||
// Write 生成Markdown内容
|
||||
func (w *MarkdownWriter) Write() ([]byte, error) {
|
||||
// 处理文档元数据
|
||||
if w.opts.IncludeMetadata {
|
||||
w.writeMetadata()
|
||||
}
|
||||
|
||||
// 遍历文档段落
|
||||
if w.doc.Body != nil {
|
||||
for _, para := range w.doc.Body.GetParagraphs() {
|
||||
err := w.writeParagraph(para)
|
||||
if err != nil {
|
||||
if w.opts.ErrorCallback != nil {
|
||||
w.opts.ErrorCallback(err)
|
||||
}
|
||||
if !w.opts.IgnoreErrors {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理表格
|
||||
for _, table := range w.doc.Body.GetTables() {
|
||||
err := w.writeTable(table)
|
||||
if err != nil {
|
||||
if w.opts.ErrorCallback != nil {
|
||||
w.opts.ErrorCallback(err)
|
||||
}
|
||||
if !w.opts.IgnoreErrors {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 添加脚注
|
||||
if w.opts.PreserveFootnotes && len(w.footnotes) > 0 {
|
||||
w.writeFootnotes()
|
||||
}
|
||||
|
||||
return []byte(w.output.String()), nil
|
||||
}
|
||||
|
||||
// writeMetadata 写入文档元数据
|
||||
func (w *MarkdownWriter) writeMetadata() {
|
||||
w.output.WriteString("---\n")
|
||||
w.output.WriteString("title: \"Document\"\n")
|
||||
w.output.WriteString("---\n\n")
|
||||
}
|
||||
|
||||
// writeParagraph 写入段落
|
||||
func (w *MarkdownWriter) writeParagraph(para *document.Paragraph) error {
|
||||
if para == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 检查段落样式
|
||||
style := w.getParagraphStyle(para)
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(style, "Heading"):
|
||||
return w.writeHeading(para, style)
|
||||
case style == "Quote":
|
||||
return w.writeQuote(para)
|
||||
case style == "CodeBlock":
|
||||
return w.writeCodeBlock(para)
|
||||
case w.isListParagraph(para):
|
||||
return w.writeListItem(para)
|
||||
default:
|
||||
return w.writeNormalParagraph(para)
|
||||
}
|
||||
}
|
||||
|
||||
// writeHeading 写入标题
|
||||
func (w *MarkdownWriter) writeHeading(para *document.Paragraph, style string) error {
|
||||
level := w.getHeadingLevel(style)
|
||||
if level > 6 {
|
||||
level = 6
|
||||
}
|
||||
|
||||
text := w.extractParagraphText(para)
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if w.opts.UseSetext && level <= 2 {
|
||||
// 使用Setext样式
|
||||
w.output.WriteString(text + "\n")
|
||||
if level == 1 {
|
||||
w.output.WriteString(strings.Repeat("=", len(text)) + "\n\n")
|
||||
} else {
|
||||
w.output.WriteString(strings.Repeat("-", len(text)) + "\n\n")
|
||||
}
|
||||
} else {
|
||||
// 使用ATX样式
|
||||
w.output.WriteString(strings.Repeat("#", level) + " " + text + "\n\n")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeQuote 写入引用
|
||||
func (w *MarkdownWriter) writeQuote(para *document.Paragraph) error {
|
||||
text := w.extractParagraphText(para)
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
lines := strings.Split(text, "\n")
|
||||
for _, line := range lines {
|
||||
w.output.WriteString("> " + line + "\n")
|
||||
}
|
||||
w.output.WriteString("\n")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeCodeBlock 写入代码块
|
||||
func (w *MarkdownWriter) writeCodeBlock(para *document.Paragraph) error {
|
||||
text := w.extractParagraphText(para)
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
lang := w.opts.DefaultCodeLang
|
||||
w.output.WriteString("```" + lang + "\n")
|
||||
w.output.WriteString(text + "\n")
|
||||
w.output.WriteString("```\n\n")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeListItem 写入列表项
|
||||
func (w *MarkdownWriter) writeListItem(para *document.Paragraph) error {
|
||||
text := w.extractParagraphText(para)
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 简单的列表项处理
|
||||
marker := w.opts.BulletListMarker
|
||||
if w.isNumberedList(para) {
|
||||
marker = "1."
|
||||
}
|
||||
|
||||
w.output.WriteString(marker + " " + text + "\n")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeNormalParagraph 写入普通段落
|
||||
func (w *MarkdownWriter) writeNormalParagraph(para *document.Paragraph) error {
|
||||
text := w.extractParagraphText(para)
|
||||
if strings.TrimSpace(text) == "" {
|
||||
w.output.WriteString("\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
// 处理长行换行
|
||||
if w.opts.WrapLongLines && len(text) > w.opts.MaxLineLength {
|
||||
text = w.wrapText(text, w.opts.MaxLineLength)
|
||||
}
|
||||
|
||||
w.output.WriteString(text + "\n\n")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeTable 写入表格
|
||||
func (w *MarkdownWriter) writeTable(table *document.Table) error {
|
||||
if table == nil || len(table.Rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !w.opts.UseGFMTables {
|
||||
return w.writeSimpleTable(table)
|
||||
}
|
||||
|
||||
rows := table.Rows
|
||||
|
||||
// 写表头
|
||||
if len(rows) > 0 {
|
||||
headerRow := rows[0]
|
||||
w.output.WriteString("|")
|
||||
for _, cell := range headerRow.Cells {
|
||||
text := w.extractCellText(&cell)
|
||||
w.output.WriteString(" " + text + " |")
|
||||
}
|
||||
w.output.WriteString("\n")
|
||||
|
||||
// 写分隔行
|
||||
w.output.WriteString("|")
|
||||
for range headerRow.Cells {
|
||||
w.output.WriteString("-----|")
|
||||
}
|
||||
w.output.WriteString("\n")
|
||||
|
||||
// 写数据行
|
||||
for i := 1; i < len(rows); i++ {
|
||||
w.output.WriteString("|")
|
||||
for _, cell := range rows[i].Cells {
|
||||
text := w.extractCellText(&cell)
|
||||
w.output.WriteString(" " + text + " |")
|
||||
}
|
||||
w.output.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
w.output.WriteString("\n")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeSimpleTable 写入简单表格格式
|
||||
func (w *MarkdownWriter) writeSimpleTable(table *document.Table) error {
|
||||
for i, row := range table.Rows {
|
||||
if i == 0 {
|
||||
w.output.WriteString("**")
|
||||
}
|
||||
for j, cell := range row.Cells {
|
||||
if j > 0 {
|
||||
w.output.WriteString(" | ")
|
||||
}
|
||||
text := w.extractCellText(&cell)
|
||||
w.output.WriteString(text)
|
||||
}
|
||||
if i == 0 {
|
||||
w.output.WriteString("**")
|
||||
}
|
||||
w.output.WriteString("\n")
|
||||
}
|
||||
w.output.WriteString("\n")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeFootnotes 写入脚注
|
||||
func (w *MarkdownWriter) writeFootnotes() {
|
||||
w.output.WriteString("\n---\n\n")
|
||||
for i, footnote := range w.footnotes {
|
||||
w.output.WriteString(fmt.Sprintf("[^%d]: %s\n", i+1, footnote))
|
||||
}
|
||||
}
|
||||
|
||||
// extractParagraphText 提取段落文本
|
||||
func (w *MarkdownWriter) extractParagraphText(para *document.Paragraph) string {
|
||||
if para == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var result strings.Builder
|
||||
|
||||
for _, run := range para.Runs {
|
||||
text := w.formatRunText(&run)
|
||||
result.WriteString(text)
|
||||
}
|
||||
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// formatRunText 格式化文本运行
|
||||
func (w *MarkdownWriter) formatRunText(run *document.Run) string {
|
||||
if run == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
text := run.Text.Content
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// 检查格式属性
|
||||
if run.Properties != nil {
|
||||
// 检查粗体
|
||||
if run.Properties.Bold != nil {
|
||||
if run.Properties.Italic != nil {
|
||||
text = "***" + text + "***" // 粗斜体
|
||||
} else {
|
||||
text = "**" + text + "**" // 粗体
|
||||
}
|
||||
} else if run.Properties.Italic != nil {
|
||||
text = w.opts.EmphasisMarker + text + w.opts.EmphasisMarker // 斜体
|
||||
}
|
||||
|
||||
// 检查删除线
|
||||
if run.Properties.Strike != nil {
|
||||
text = "~~" + text + "~~" // 删除线
|
||||
}
|
||||
|
||||
// 处理代码样式
|
||||
if w.isCodeStyle(run.Properties) {
|
||||
text = "`" + text + "`"
|
||||
}
|
||||
}
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
// extractCellText 提取单元格文本
|
||||
func (w *MarkdownWriter) extractCellText(cell *document.TableCell) string {
|
||||
if cell == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var result strings.Builder
|
||||
|
||||
for _, para := range cell.Paragraphs {
|
||||
text := w.extractParagraphText(¶)
|
||||
result.WriteString(text)
|
||||
}
|
||||
|
||||
// 清理表格单元格中的换行符
|
||||
text := result.String()
|
||||
text = strings.ReplaceAll(text, "\n", " ")
|
||||
text = strings.TrimSpace(text)
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
// getParagraphStyle 获取段落样式
|
||||
func (w *MarkdownWriter) getParagraphStyle(para *document.Paragraph) string {
|
||||
if para.Properties != nil && para.Properties.ParagraphStyle != nil {
|
||||
return para.Properties.ParagraphStyle.Val
|
||||
}
|
||||
return "Normal"
|
||||
}
|
||||
|
||||
// getHeadingLevel 获取标题级别
|
||||
func (w *MarkdownWriter) getHeadingLevel(style string) int {
|
||||
// 提取数字
|
||||
re := regexp.MustCompile(`\d+`)
|
||||
matches := re.FindString(style)
|
||||
if matches != "" {
|
||||
if level, err := strconv.Atoi(matches); err == nil {
|
||||
return level
|
||||
}
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
// isListParagraph 判断是否为列表段落
|
||||
func (w *MarkdownWriter) isListParagraph(para *document.Paragraph) bool {
|
||||
if para.Properties == nil {
|
||||
return false
|
||||
}
|
||||
return para.Properties.NumberingProperties != nil
|
||||
}
|
||||
|
||||
// isNumberedList 判断是否为编号列表
|
||||
func (w *MarkdownWriter) isNumberedList(para *document.Paragraph) bool {
|
||||
// 简单实现,实际应该检查编号格式
|
||||
return false
|
||||
}
|
||||
|
||||
// isCodeStyle 判断是否为代码样式
|
||||
func (w *MarkdownWriter) isCodeStyle(props *document.RunProperties) bool {
|
||||
if props.FontFamily != nil {
|
||||
font := props.FontFamily.ASCII
|
||||
// 检查是否为等宽字体
|
||||
codefonts := []string{"Consolas", "Courier New", "Monaco", "Menlo", "Source Code Pro"}
|
||||
for _, codefont := range codefonts {
|
||||
if strings.Contains(font, codefont) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// wrapText 文本换行
|
||||
func (w *MarkdownWriter) wrapText(text string, maxLength int) string {
|
||||
if len(text) <= maxLength {
|
||||
return text
|
||||
}
|
||||
|
||||
var result strings.Builder
|
||||
words := strings.Fields(text)
|
||||
var line strings.Builder
|
||||
|
||||
for _, word := range words {
|
||||
if line.Len()+len(word)+1 > maxLength {
|
||||
if line.Len() > 0 {
|
||||
result.WriteString(line.String() + "\n")
|
||||
line.Reset()
|
||||
}
|
||||
}
|
||||
if line.Len() > 0 {
|
||||
line.WriteString(" ")
|
||||
}
|
||||
line.WriteString(word)
|
||||
}
|
||||
|
||||
if line.Len() > 0 {
|
||||
result.WriteString(line.String())
|
||||
}
|
||||
|
||||
return result.String()
|
||||
}
|
238
test/markdown_table_tasklist_test.go
Normal file
238
test/markdown_table_tasklist_test.go
Normal file
@@ -0,0 +1,238 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/ZeroHawkeye/wordZero/pkg/markdown"
|
||||
)
|
||||
|
||||
// TestMarkdownTableConversion 测试Markdown表格转换
|
||||
func TestMarkdownTableConversion(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
markdown string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "简单表格",
|
||||
markdown: `| 姓名 | 年龄 | 城市 |
|
||||
|------|------|------|
|
||||
| 张三 | 25 | 北京 |
|
||||
| 李四 | 30 | 上海 |`,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "对齐表格",
|
||||
markdown: `| 左对齐 | 居中对齐 | 右对齐 |
|
||||
|:-------|:--------:|-------:|
|
||||
| 内容1 | 内容2 | 内容3 |`,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "复杂表格",
|
||||
markdown: `| 功能 | 状态 | 描述 |
|
||||
|------|------|------|
|
||||
| **表格支持** | ✅ | 完整的GFM表格转换 |
|
||||
| *任务列表* | ✅ | 支持复选框显示 |
|
||||
| 代码块 | ✅ | 等宽字体显示 |`,
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
opts := markdown.DefaultOptions()
|
||||
opts.EnableTables = true
|
||||
converter := markdown.NewConverter(opts)
|
||||
|
||||
doc, err := converter.ConvertString(tt.markdown, opts)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ConvertString() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if doc == nil && !tt.wantErr {
|
||||
t.Error("ConvertString() returned nil document")
|
||||
return
|
||||
}
|
||||
|
||||
// 验证文档包含表格
|
||||
if doc != nil {
|
||||
tables := doc.Body.GetTables()
|
||||
if len(tables) == 0 {
|
||||
t.Error("Expected document to contain at least one table")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMarkdownTaskListConversion 测试Markdown任务列表转换
|
||||
func TestMarkdownTaskListConversion(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
markdown string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "简单任务列表",
|
||||
markdown: `- [x] 已完成任务
|
||||
- [ ] 未完成任务`,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "嵌套任务列表",
|
||||
markdown: `- [x] 主要任务
|
||||
- [x] 子任务1
|
||||
- [ ] 子任务2
|
||||
- [ ] 另一个主要任务`,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "混合格式任务列表",
|
||||
markdown: `- [x] **重要**已完成任务
|
||||
- [ ] *普通*未完成任务
|
||||
- [x] 包含代码的任务`,
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
opts := markdown.DefaultOptions()
|
||||
opts.EnableTaskList = true
|
||||
converter := markdown.NewConverter(opts)
|
||||
|
||||
doc, err := converter.ConvertString(tt.markdown, opts)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ConvertString() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if doc == nil && !tt.wantErr {
|
||||
t.Error("ConvertString() returned nil document")
|
||||
return
|
||||
}
|
||||
|
||||
// 验证文档包含段落(任务列表项)
|
||||
if doc != nil {
|
||||
paragraphs := doc.Body.GetParagraphs()
|
||||
if len(paragraphs) == 0 {
|
||||
t.Error("Expected document to contain at least one paragraph for task items")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMarkdownCombinedFeatures 测试表格和任务列表的组合使用
|
||||
func TestMarkdownCombinedFeatures(t *testing.T) {
|
||||
markdownContent := `# 项目进度
|
||||
|
||||
## 功能列表
|
||||
|
||||
| 功能 | 状态 | 备注 |
|
||||
|------|------|------|
|
||||
| 表格 | ✅ | 已实现 |
|
||||
| 任务列表 | ✅ | 已实现 |
|
||||
|
||||
## 待办事项
|
||||
|
||||
- [x] 实现表格功能
|
||||
- [x] 实现任务列表功能
|
||||
- [ ] 编写文档
|
||||
- [ ] 完善测试`
|
||||
|
||||
opts := markdown.HighQualityOptions()
|
||||
opts.EnableTables = true
|
||||
opts.EnableTaskList = true
|
||||
converter := markdown.NewConverter(opts)
|
||||
|
||||
doc, err := converter.ConvertString(markdownContent, opts)
|
||||
if err != nil {
|
||||
t.Errorf("ConvertString() error = %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if doc == nil {
|
||||
t.Error("ConvertString() returned nil document")
|
||||
return
|
||||
}
|
||||
|
||||
// 验证包含表格
|
||||
tables := doc.Body.GetTables()
|
||||
if len(tables) == 0 {
|
||||
t.Error("Expected document to contain at least one table")
|
||||
}
|
||||
|
||||
// 验证包含段落(任务列表和其他内容)
|
||||
paragraphs := doc.Body.GetParagraphs()
|
||||
if len(paragraphs) == 0 {
|
||||
t.Error("Expected document to contain paragraphs")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTableAlignment 测试表格对齐功能
|
||||
func TestTableAlignment(t *testing.T) {
|
||||
markdownContent := `| 左对齐 | 居中 | 右对齐 |
|
||||
|:-------|:----:|-------:|
|
||||
| Left | Center | Right |`
|
||||
|
||||
opts := markdown.DefaultOptions()
|
||||
opts.EnableTables = true
|
||||
converter := markdown.NewConverter(opts)
|
||||
|
||||
doc, err := converter.ConvertString(markdownContent, opts)
|
||||
if err != nil {
|
||||
t.Errorf("ConvertString() error = %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if doc == nil {
|
||||
t.Error("ConvertString() returned nil document")
|
||||
return
|
||||
}
|
||||
|
||||
// 验证表格存在
|
||||
tables := doc.Body.GetTables()
|
||||
if len(tables) != 1 {
|
||||
t.Errorf("Expected 1 table, got %d", len(tables))
|
||||
return
|
||||
}
|
||||
|
||||
table := tables[0]
|
||||
if table.GetRowCount() != 2 {
|
||||
t.Errorf("Expected 2 rows, got %d", table.GetRowCount())
|
||||
}
|
||||
|
||||
if table.GetColumnCount() != 3 {
|
||||
t.Errorf("Expected 3 columns, got %d", table.GetColumnCount())
|
||||
}
|
||||
}
|
||||
|
||||
// TestTaskListCheckboxes 测试任务列表复选框
|
||||
func TestTaskListCheckboxes(t *testing.T) {
|
||||
markdownContent := `- [x] 选中的任务
|
||||
- [ ] 未选中的任务`
|
||||
|
||||
opts := markdown.DefaultOptions()
|
||||
opts.EnableTaskList = true
|
||||
converter := markdown.NewConverter(opts)
|
||||
|
||||
doc, err := converter.ConvertString(markdownContent, opts)
|
||||
if err != nil {
|
||||
t.Errorf("ConvertString() error = %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if doc == nil {
|
||||
t.Error("ConvertString() returned nil document")
|
||||
return
|
||||
}
|
||||
|
||||
// 验证包含段落
|
||||
paragraphs := doc.Body.GetParagraphs()
|
||||
if len(paragraphs) < 2 {
|
||||
t.Errorf("Expected at least 2 paragraphs for task items, got %d", len(paragraphs))
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user