From 3002f124d5f6f44a1538f6b4a0cf845b25a14f2e Mon Sep 17 00:00:00 2001 From: zero <166997982@qq.com> Date: Wed, 4 Jun 2025 13:44:08 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0markdown=E5=8F=8C=E5=90=91?= =?UTF-8?q?=E8=BD=AC=E6=8D=A2=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 74 +++ examples/markdown_conversion/main.go | 300 ++++++++++ .../markdown_demo/table_and_tasklist_demo.go | 74 +++ examples/table/table_example.go | 2 +- go.mod | 6 +- go.sum | 2 + pkg/document/README.md | 237 +++++++- pkg/markdown/README.md | 211 ++++++++ pkg/markdown/config.go | 69 +++ pkg/markdown/converter.go | 162 ++++++ pkg/markdown/errors.go | 91 ++++ pkg/markdown/exporter.go | 196 +++++++ pkg/markdown/renderer.go | 512 ++++++++++++++++++ pkg/markdown/writer.go | 464 ++++++++++++++++ test/markdown_table_tasklist_test.go | 238 ++++++++ 15 files changed, 2635 insertions(+), 3 deletions(-) create mode 100644 examples/markdown_conversion/main.go create mode 100644 examples/markdown_demo/table_and_tasklist_demo.go create mode 100644 go.sum create mode 100644 pkg/markdown/README.md create mode 100644 pkg/markdown/config.go create mode 100644 pkg/markdown/converter.go create mode 100644 pkg/markdown/errors.go create mode 100644 pkg/markdown/exporter.go create mode 100644 pkg/markdown/renderer.go create mode 100644 pkg/markdown/writer.go create mode 100644 test/markdown_table_tasklist_test.go diff --git a/README.md b/README.md index 2220e8a..1bac9ae 100644 --- a/README.md +++ b/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转换 ✨ **新增** ### 🚧 规划中功能 - 表格排序和高级操作 diff --git a/examples/markdown_conversion/main.go b/examples/markdown_conversion/main.go new file mode 100644 index 0000000..d1599a4 --- /dev/null +++ b/examples/markdown_conversion/main.go @@ -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) +} diff --git a/examples/markdown_demo/table_and_tasklist_demo.go b/examples/markdown_demo/table_and_tasklist_demo.go new file mode 100644 index 0000000..640eea8 --- /dev/null +++ b/examples/markdown_demo/table_and_tasklist_demo.go @@ -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(" • 混合格式文本支持") +} diff --git a/examples/table/table_example.go b/examples/table/table_example.go index 8c30f25..c1fba99 100644 --- a/examples/table/table_example.go +++ b/examples/table/table_example.go @@ -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) diff --git a/go.mod b/go.mod index d54895f..6cfd0b0 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..9fb7596 --- /dev/null +++ b/go.sum @@ -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= diff --git a/pkg/document/README.md b/pkg/document/README.md index 018b526..33ce631 100644 --- a/pkg/document/README.md +++ b/pkg/document/README.md @@ -747,4 +747,239 @@ doc.Save("example.docx") 10. 图片支持PNG、JPEG、GIF格式,会自动嵌入到文档中 11. 图片尺寸可以用毫米或像素指定,支持保持长宽比的缩放 12. 图片位置支持嵌入式、左浮动、右浮动等多种布局方式 -13. 图片对齐功能仅适用于嵌入式图片(ImagePositionInline),浮动图片请使用位置控制 \ No newline at end of file +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)`) - 转换为蓝色文本(后续支持超链接) +- **图片** (`![alt](src)`) - 转换为图片占位符(后续支持图片嵌入) + +### 使用示例 + +#### 基础字符串转换 +```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,保持完全兼容 \ No newline at end of file diff --git a/pkg/markdown/README.md b/pkg/markdown/README.md new file mode 100644 index 0000000..ca7e5d8 --- /dev/null +++ b/pkg/markdown/README.md @@ -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)` | 链接转换 | +| 图片 | `![图片](src)` | 图片引用 | +| 表格 | `\| 表格 \|` | GFM表格 | +| 列表 | `- 项目` | 列表项 | + +### Markdown → Word + +| Markdown语法 | Word元素 | 实现方式 | +|-------------|----------|----------| +| `# 标题` | Heading1样式 | `AddHeadingParagraph()` | +| `**粗体**` | 粗体格式 | `RunProperties.Bold` | +| `*斜体*` | 斜体格式 | `RunProperties.Italic` | +| `` `代码` `` | 代码样式 | 等宽字体 | +| `[链接](url)` | 超链接 | `AddHyperlink()` | +| `![图片](src)` | 图片 | `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图表转换 +- [ ] 更好的列表嵌套支持 +- [ ] 自定义样式映射 +- [ ] 命令行工具 \ No newline at end of file diff --git a/pkg/markdown/config.go b/pkg/markdown/config.go new file mode 100644 index 0000000..65201c0 --- /dev/null +++ b/pkg/markdown/config.go @@ -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 +} diff --git a/pkg/markdown/converter.go b/pkg/markdown/converter.go new file mode 100644 index 0000000..f494455 --- /dev/null +++ b/pkg/markdown/converter.go @@ -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 +} diff --git a/pkg/markdown/errors.go b/pkg/markdown/errors.go new file mode 100644 index 0000000..fa7750b --- /dev/null +++ b/pkg/markdown/errors.go @@ -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, + } +} diff --git a/pkg/markdown/exporter.go b/pkg/markdown/exporter.go new file mode 100644 index 0000000..b4c8d0f --- /dev/null +++ b/pkg/markdown/exporter.go @@ -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) + } +} diff --git a/pkg/markdown/renderer.go b/pkg/markdown/renderer.go new file mode 100644 index 0000000..3329f9f --- /dev/null +++ b/pkg/markdown/renderer.go @@ -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) + } + } + } +} diff --git a/pkg/markdown/writer.go b/pkg/markdown/writer.go new file mode 100644 index 0000000..788e72e --- /dev/null +++ b/pkg/markdown/writer.go @@ -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() +} diff --git a/test/markdown_table_tasklist_test.go b/test/markdown_table_tasklist_test.go new file mode 100644 index 0000000..ac7f93c --- /dev/null +++ b/test/markdown_table_tasklist_test.go @@ -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)) + } +}