feat(document): 支持 CSV/MD 格式,增强 Excel/PDF 的 Markdown 转换能力 (v1.0.6) (by AI)

This commit is contained in:
AI Engineer 2026-05-13 22:55:38 +08:00
parent cc8f0a5acc
commit f1897b701f
9 changed files with 275 additions and 25 deletions

View File

@ -1,5 +1,12 @@
# CHANGELOG
## v1.0.6 (2026-05-13)
- **新特性**: 支持 `.csv` 格式,支持对象数组与 Markdown 表格转换。
- **新特性**: 支持 `.md``.txt` 格式,统一纳入 `Document` 接口管理。
- **功能增强**: `Excel.ToMarkdown` 现在支持提取所有工作表 (Sheet),并自动添加标题。
- **功能增强**: `PDF.ToMarkdown` 增加页码标记 (`<!-- Page X -->`),方便 RAG 场景按页分片。
- **功能增强**: Markdown 表格转换增加对管道符 (`|`) 的转义处理。
## v1.0.5 (2026-05-13)
- **新特性**: 引入 `Graph` 关系型文档类型 (`.graph`),支持节点内容与关系的统一建模。
- **新特性**: 关系型文档支持一键转换为带 **Mermaid** 图表的 Markdown适用于脑图与分镜。

View File

@ -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":...}` | 返回原始文本 | 保存为文本 |
## 快速示例

View File

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

101
csv.go Normal file
View File

@ -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())
}

View File

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

View File

@ -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())
}
// --- 基础操作方法 ---

52
markdown.go Normal file
View File

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

54
misc_test.go Normal file
View File

@ -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())
}
}

23
pdf.go
View File

@ -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("<!-- Page %d -->\n", i))
for _, text := range t {
sb.WriteString(text.S)
}
}
}
p.Content = strings.TrimSpace(sb.String())
}
return p, nil