diff --git a/CHANGELOG.md b/CHANGELOG.md index 8713d7b..e944d3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # CHANGELOG +## v1.0.6 (2026-05-13) +- **新特性**: 支持 `.csv` 格式,支持对象数组与 Markdown 表格转换。 +- **新特性**: 支持 `.md` 和 `.txt` 格式,统一纳入 `Document` 接口管理。 +- **功能增强**: `Excel.ToMarkdown` 现在支持提取所有工作表 (Sheet),并自动添加标题。 +- **功能增强**: `PDF.ToMarkdown` 增加页码标记 (``),方便 RAG 场景按页分片。 +- **功能增强**: Markdown 表格转换增加对管道符 (`|`) 的转义处理。 + ## v1.0.5 (2026-05-13) - **新特性**: 引入 `Graph` 关系型文档类型 (`.graph`),支持节点内容与关系的统一建模。 - **新特性**: 关系型文档支持一键转换为带 **Mermaid** 图表的 Markdown,适用于脑图与分镜。 diff --git a/README.md b/README.md index 28e41e8..c430432 100644 --- a/README.md +++ b/README.md @@ -33,11 +33,14 @@ doc.Save("updated.xlsx") | 格式 | 后缀 | `ToJSON` 表现 | `ToMarkdown` 表现 | `Save` 行为 | | :--- | :--- | :--- | :--- | :--- | -| **Excel** | `.xlsx` | 返回第一个 Sheet 的对象数组 | 返回第一个 Sheet 的 MD 表格 | 保存为 Excel 文件 | +| **Excel** | `.xlsx` | 返回第一个 Sheet 的对象数组 | 返回**所有 Sheet** 的 MD 表格 | 保存为 Excel | | **Word** | `.docx` | 返回 `{"metadata":..., "content":...}` | 返回**带层级标题**的 Markdown | 保存为文本 | | **PPT** | `.pptx` | 返回 `{"metadata":..., "content":...}` | 返回提取的全文文本 | 保存为文本 | -| **PDF** | `.pdf` | 返回 `{"metadata":..., "content":...}` | 返回提取的全文文本 | 保存为文本 | +| **PDF** | `.pdf` | 返回 `{"metadata":..., "content":...}` | 返回**带页码标记**的 Markdown | 保存为文本 | | **Graph** | `.graph` | 返回节点与关系的结构化 JSON | 返回 **Mermaid 关系图** + 节点详情 | 保存为 JSON | +| **CSV** | `.csv` | 返回对象数组 | 返回 MD 表格 | 保存为 CSV | +| **Markdown**| `.md` | 返回 `{"content":...}` | 返回原始文本 | 保存为 Markdown | +| **Text** | `.txt` | 返回 `{"content":...}` | 返回原始文本 | 保存为文本 | ## 快速示例 diff --git a/TEST.md b/TEST.md index 751c9c7..cd9c349 100644 --- a/TEST.md +++ b/TEST.md @@ -7,6 +7,9 @@ - [x] **ID 生成解析**: 验证 `MakeCellID` 和 `ParseCellID` 的准确性。 - [x] **Docx 标题识别**: 通过 XML 提取带 `#` 的层级 Markdown。 - [x] **Graph 关系文档**: 验证 `Node` 增删、`ToMarkdown` (Mermaid) 及 `Save/Open`。 +- [x] **CSV/Markdown 支持**: 验证 CSV 转对象/表格,Markdown 文件的统一接口读写。 +- [x] **多 Sheet Excel**: 验证 `ToMarkdown` 导出所有工作表。 +- [x] **PDF 页码标记**: 验证提取内容中包含页码注释。 ## 性能测试 (Benchmark) - **环境**: Darwin / Apple M3 Max diff --git a/csv.go b/csv.go new file mode 100644 index 0000000..7c1a768 --- /dev/null +++ b/csv.go @@ -0,0 +1,101 @@ +package document + +import ( + "encoding/csv" + "strings" + + "apigo.cc/go/cast" + "apigo.cc/go/file" +) + +// CSV 封装了 CSV 文件的读写。 +type CSV struct { + filename string + Data [][]string +} + +// OpenCSV 打开一个 CSV 文件。 +func OpenCSV(filename string) (*CSV, error) { + if !file.Exists(filename) { + return &CSV{filename: filename}, nil + } + + content, err := file.Read(filename) + if err != nil { + return nil, err + } + + r := csv.NewReader(strings.NewReader(content)) + data, err := r.ReadAll() + if err != nil { + return nil, err + } + + return &CSV{ + filename: filename, + Data: data, + }, nil +} + +// ToJSON 返回 JSON 数组。 +func (c *CSV) ToJSON() string { + if len(c.Data) == 0 { + return "[]" + } + + header := c.Data[0] + var res []map[string]string + for i := 1; i < len(c.Data); i++ { + row := make(map[string]string) + for j, h := range header { + if j < len(c.Data[i]) { + row[h] = c.Data[i][j] + } + } + res = append(res, row) + } + + jsonStr, _ := cast.ToJSON(res) + return jsonStr +} + +// ToMarkdown 返回 Markdown 表格。 +func (c *CSV) ToMarkdown() string { + if len(c.Data) == 0 { + return "" + } + + var sb strings.Builder + for i, row := range c.Data { + sb.WriteString("| ") + for _, col := range row { + val := strings.ReplaceAll(col, "|", "\\|") + sb.WriteString(val) + sb.WriteString(" | ") + } + sb.WriteString("\n") + if i == 0 { + sb.WriteString("|") + for range row { + sb.WriteString(" --- |") + } + sb.WriteString("\n") + } + } + return sb.String() +} + +// Save 保存为 CSV 文件。 +func (c *CSV) Save(filename ...string) error { + path := c.filename + if len(filename) > 0 && filename[0] != "" { + path = filename[0] + } + + var sb strings.Builder + w := csv.NewWriter(&sb) + w.WriteAll(c.Data) + w.Flush() + + return file.Write(path, sb.String()) +} diff --git a/document.go b/document.go index 3ea6032..81d87ce 100644 --- a/document.go +++ b/document.go @@ -30,6 +30,10 @@ func Open(filename string, password ...string) (Document, error) { return OpenPDF(filename) case ".graph": return OpenGraph(filename) + case ".csv": + return OpenCSV(filename) + case ".md", ".markdown", ".txt": + return OpenMarkdown(filename) default: return nil, fmt.Errorf("unsupported file type: %s", ext) } @@ -46,6 +50,10 @@ func Create(ext string) (Document, error) { return nil, fmt.Errorf("creating docx not supported yet") case "graph": return NewGraph(), nil + case "csv": + return &CSV{}, nil + case "md", "markdown": + return &Markdown{}, nil default: return nil, fmt.Errorf("unsupported creation type: %s", ext) } diff --git a/excel.go b/excel.go index efbe208..0e4b809 100644 --- a/excel.go +++ b/excel.go @@ -72,35 +72,46 @@ func (xls *Excel) ToJSON() string { return res } -// ToMarkdown 将第一个工作表的数据转换为 Markdown 表格。 +// ToMarkdown 将所有工作表的数据转换为 Markdown 格式。 func (xls *Excel) ToMarkdown() string { sheets := xls.Sheets() if len(sheets) == 0 { return "" } - rows, _ := xls.Get(sheets[0], "A1", "") - if len(rows) == 0 { - return "" - } var sb strings.Builder - for i, row := range rows { - sb.WriteString("| ") - for _, col := range row { - sb.WriteString(cast.To[string](col)) - sb.WriteString(" | ") + for _, sheetName := range sheets { + rows, _ := xls.Get(sheetName, "A1", "") + if len(rows) == 0 { + continue } - sb.WriteString("\n") - if i == 0 { - // 分隔线 - sb.WriteString("|") - for range row { - sb.WriteString(" --- |") + + if len(sheets) > 1 { + sb.WriteString("## Sheet: " + sheetName + "\n\n") + } + + for i, row := range rows { + sb.WriteString("| ") + for _, col := range row { + val := cast.To[string](col) + // 转义 Markdown 表格中的管道符 + val = strings.ReplaceAll(val, "|", "\\|") + sb.WriteString(val) + sb.WriteString(" | ") } sb.WriteString("\n") + if i == 0 { + // 分隔线 + sb.WriteString("|") + for range row { + sb.WriteString(" --- |") + } + sb.WriteString("\n") + } } + sb.WriteString("\n") } - return sb.String() + return strings.TrimSpace(sb.String()) } // --- 基础操作方法 --- diff --git a/markdown.go b/markdown.go new file mode 100644 index 0000000..416b193 --- /dev/null +++ b/markdown.go @@ -0,0 +1,52 @@ +package document + +import ( + "fmt" + + "apigo.cc/go/cast" + "apigo.cc/go/file" +) + +// Markdown 封装了纯 Markdown 文档的读写,使其符合 Document 接口。 +type Markdown struct { + filename string + Content string +} + +// OpenMarkdown 打开一个 Markdown 文件。 +func OpenMarkdown(filename string) (*Markdown, error) { + m := &Markdown{filename: filename} + if file.Exists(filename) { + content, err := file.Read(filename) + if err != nil { + return nil, err + } + m.Content = content + } + return m, nil +} + +// ToJSON 返回包含内容的 JSON 字符串。 +func (m *Markdown) ToJSON() string { + res, _ := cast.ToJSON(map[string]string{ + "content": m.Content, + }) + return res +} + +// ToMarkdown 直接返回其内容。 +func (m *Markdown) ToMarkdown() string { + return m.Content +} + +// Save 保存为 Markdown 文件。 +func (m *Markdown) Save(filename ...string) error { + path := m.filename + if len(filename) > 0 && filename[0] != "" { + path = filename[0] + } + if path == "" { + return fmt.Errorf("no filename specified") + } + return file.Write(path, m.Content) +} diff --git a/misc_test.go b/misc_test.go new file mode 100644 index 0000000..aa816c1 --- /dev/null +++ b/misc_test.go @@ -0,0 +1,54 @@ +package document + +import ( + "strings" + "testing" + + "apigo.cc/go/file" +) + +func TestCSV(t *testing.T) { + filename := "test.csv" + defer file.Remove(filename) + + c, _ := OpenCSV(filename) + c.Data = [][]string{ + {"Name", "Age"}, + {"Alice", "25"}, + {"Bob", "30"}, + } + c.Save() + + doc, err := Open(filename) + if err != nil { + t.Fatalf("Open failed: %v", err) + } + + md := doc.ToMarkdown() + if !strings.Contains(md, "Alice") || !strings.Contains(md, "| --- |") { + t.Errorf("Unexpected Markdown: %s", md) + } + + jsonStr := doc.ToJSON() + if !strings.Contains(jsonStr, "\"Name\":\"Alice\"") { + t.Errorf("Unexpected JSON: %s", jsonStr) + } +} + +func TestMarkdown(t *testing.T) { + filename := "test.md" + defer file.Remove(filename) + + m, _ := OpenMarkdown(filename) + m.Content = "# Hello\nWorld" + m.Save() + + doc, err := Open(filename) + if err != nil { + t.Fatalf("Open failed: %v", err) + } + + if doc.ToMarkdown() != "# Hello\nWorld" { + t.Errorf("Unexpected Markdown: %s", doc.ToMarkdown()) + } +} diff --git a/pdf.go b/pdf.go index 9718f4a..ed4ac09 100644 --- a/pdf.go +++ b/pdf.go @@ -1,9 +1,7 @@ package document import ( - "bytes" "fmt" - "io" "strings" "apigo.cc/go/cast" @@ -42,11 +40,24 @@ func OpenPDF(filename string) (*PDF, error) { } } - var b bytes.Buffer - if t, err := f.GetPlainText(); err == nil { - io.Copy(&b, t) - p.Content = b.String() + var sb strings.Builder + for i := 1; i <= f.NumPage(); i++ { + p_ := f.Page(i) + if p_.V.IsNull() { + continue + } + t := p_.Content().Text + if len(t) > 0 { + if i > 1 { + sb.WriteString("\n\n") + } + sb.WriteString(fmt.Sprintf("\n", i)) + for _, text := range t { + sb.WriteString(text.S) + } + } } + p.Content = strings.TrimSpace(sb.String()) } return p, nil