增加markdown双向转换功能

This commit is contained in:
zero
2025-06-04 13:44:08 +08:00
parent 067e7d7bfa
commit 3002f124d5
15 changed files with 2635 additions and 3 deletions

View File

@@ -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转换 ✨ **新增**
### 🚧 规划中功能
- 表格排序和高级操作

View 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)
}

View 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(" • 混合格式文本支持")
}

View File

@@ -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
View File

@@ -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
View 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=

View File

@@ -748,3 +748,238 @@ doc.Save("example.docx")
11. 图片尺寸可以用毫米或像素指定,支持保持长宽比的缩放
12. 图片位置支持嵌入式、左浮动、右浮动等多种布局方式
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保持完全兼容

211
pkg/markdown/README.md Normal file
View 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)` | 链接转换 |
| 图片 | `![图片](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图表转换
- [ ] 更好的列表嵌套支持
- [ ] 自定义样式映射
- [ ] 命令行工具

69
pkg/markdown/config.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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(&para)
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()
}

View 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))
}
}