Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1897b701f | ||
|
|
cc8f0a5acc |
14
CHANGELOG.md
14
CHANGELOG.md
@ -1,5 +1,19 @@
|
|||||||
# CHANGELOG
|
# 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,适用于脑图与分镜。
|
||||||
|
- **功能增强**: 改进 `Docx` 提取算法,支持自动识别标题层级并转换为标准的层级 Markdown (H1-H6)。
|
||||||
|
- **架构对齐**: `OpenGraph` 支持文件不存在时自动创建,与 `OpenExcel` 行为一致。
|
||||||
|
- **文档更新**: README 增加 RAG 增强与关系文档示例。
|
||||||
|
|
||||||
## v1.0.4 (2026-05-12)
|
## v1.0.4 (2026-05-12)
|
||||||
- **依赖同步**:同步更新 `go/cast` 和 `go/file` 至最新版本。
|
- **依赖同步**:同步更新 `go/cast` 和 `go/file` 至最新版本。
|
||||||
- **文档优化**:完善 README 与测试用例。
|
- **文档优化**:完善 README 与测试用例。
|
||||||
|
|||||||
44
README.md
44
README.md
@ -4,10 +4,11 @@
|
|||||||
|
|
||||||
## 核心设计
|
## 核心设计
|
||||||
|
|
||||||
`document` 包将所有复杂的文档格式(Excel, Word, PPT, PDF)抽象为统一的 `Document` 接口。你只需要关心 **数据载体** (JSON/Markdown),而不需要关心底层实现。
|
`document` 包将所有复杂的文档格式(Excel, Word, PPT, PDF, Graph)抽象为统一的 `Document` 接口。你只需要关心 **数据载体** (JSON/Markdown),而不需要关心底层实现。
|
||||||
|
|
||||||
- **结构化数据 (Excel)** <-> **JSON**
|
- **结构化数据 (Excel)** <-> **JSON**
|
||||||
- **半结构化内容 (Word/PPT/PDF)** -> **Markdown**
|
- **半结构化内容 (Word/PPT/PDF)** -> **结构化 Markdown** (支持标题识别)
|
||||||
|
- **关系型内容 (Graph/Mindmap)** <-> **JSON/Mermaid**
|
||||||
|
|
||||||
## 统一 API
|
## 统一 API
|
||||||
|
|
||||||
@ -18,10 +19,10 @@ import "apigo.cc/go/document"
|
|||||||
doc, _ := document.Open("data.xlsx")
|
doc, _ := document.Open("data.xlsx")
|
||||||
|
|
||||||
// 2. 转换为通用载体
|
// 2. 转换为通用载体
|
||||||
jsonStr := doc.ToJSON() // 适合 Excel
|
jsonStr := doc.ToJSON() // 适合 Excel / Graph
|
||||||
mdStr := doc.ToMarkdown() // 适合 Word/PPT/PDF
|
mdStr := doc.ToMarkdown() // 适合 Word / PDF / Graph (Mermaid)
|
||||||
|
|
||||||
// 3. 修改并保存 (Excel 支持数据回写)
|
// 3. 修改并保存 (Excel/Graph 支持数据回写)
|
||||||
if xls, ok := doc.(*document.Excel); ok {
|
if xls, ok := doc.(*document.Excel); ok {
|
||||||
xls.SetData("Sheet1", newData, "A1", "")
|
xls.SetData("Sheet1", newData, "A1", "")
|
||||||
}
|
}
|
||||||
@ -32,21 +33,32 @@ doc.Save("updated.xlsx")
|
|||||||
|
|
||||||
| 格式 | 后缀 | `ToJSON` 表现 | `ToMarkdown` 表现 | `Save` 行为 |
|
| 格式 | 后缀 | `ToJSON` 表现 | `ToMarkdown` 表现 | `Save` 行为 |
|
||||||
| :--- | :--- | :--- | :--- | :--- |
|
| :--- | :--- | :--- | :--- | :--- |
|
||||||
| **Excel** | `.xlsx` | 返回第一个 Sheet 的对象数组 | 返回第一个 Sheet 的 MD 表格 | 保存为 Excel 文件 |
|
| **Excel** | `.xlsx` | 返回第一个 Sheet 的对象数组 | 返回**所有 Sheet** 的 MD 表格 | 保存为 Excel |
|
||||||
| **Word** | `.docx` | 返回 `{"metadata":..., "content":...}` | 返回提取的全文文本 | 保存为纯文本文件 |
|
| **Word** | `.docx` | 返回 `{"metadata":..., "content":...}` | 返回**带层级标题**的 Markdown | 保存为文本 |
|
||||||
| **PPT** | `.pptx` | 返回 `{"metadata":..., "content":...}` | 返回提取的全文文本 | 保存为纯文本文件 |
|
| **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":...}` | 返回原始文本 | 保存为文本 |
|
||||||
|
|
||||||
## 快速示例
|
## 快速示例
|
||||||
|
|
||||||
### AI 友好型转换
|
### AI 友好型转换 (RAG 增强)
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// 一行代码将 PDF 转为 AI 易读的 Markdown
|
// 一行代码将 Word 转为带层级标题的 Markdown,方便 AI 分片 (Chunking)
|
||||||
md, _ := document.Open("report.pdf").ToMarkdown()
|
md, _ := document.Open("manual.docx").ToMarkdown()
|
||||||
|
```
|
||||||
|
|
||||||
// 一行代码将 Excel 转为结构化 JSON
|
### 关系型文档 (脑图/小说大纲/分镜脚本)
|
||||||
json, _ := document.Open("sales.xlsx").ToJSON()
|
|
||||||
|
```go
|
||||||
|
g := document.NewGraph()
|
||||||
|
g.Title = "故事大纲"
|
||||||
|
g.AddNode(&document.Node{ID: "1", Title: "开端", Content: "主角获得系统", Type: "plot"})
|
||||||
|
g.AddNode(&document.Node{ID: "2", Title: "冲突", Content: "反派出现", Links: []string{"1"}})
|
||||||
|
mdStr := g.ToMarkdown() // 包含 Mermaid graph TD 的渲染内容
|
||||||
```
|
```
|
||||||
|
|
||||||
## API 参考
|
## API 参考
|
||||||
@ -59,3 +71,7 @@ json, _ := document.Open("sales.xlsx").ToJSON()
|
|||||||
- `ToJSON() string`
|
- `ToJSON() string`
|
||||||
- `ToMarkdown() string`
|
- `ToMarkdown() string`
|
||||||
- `Save(filename ...string) error`
|
- `Save(filename ...string) error`
|
||||||
|
|
||||||
|
### Graph 专用 (关系型文档)
|
||||||
|
- `AddNode(n *Node)`
|
||||||
|
- `OpenGraph(filename string) (*Graph, error)`
|
||||||
|
|||||||
8
TEST.md
8
TEST.md
@ -5,9 +5,14 @@
|
|||||||
- [x] **Excel 对象映射**: 验证 `SetData`, `GetData` 及动态列扩展。
|
- [x] **Excel 对象映射**: 验证 `SetData`, `GetData` 及动态列扩展。
|
||||||
- [x] **统一 API**: 验证 `document.Open` 自动识别、`ToJSON` 和 `ToMarkdown`。
|
- [x] **统一 API**: 验证 `document.Open` 自动识别、`ToJSON` 和 `ToMarkdown`。
|
||||||
- [x] **ID 生成解析**: 验证 `MakeCellID` 和 `ParseCellID` 的准确性。
|
- [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)
|
## 性能测试 (Benchmark)
|
||||||
- **环境**: Darwin / Intel(R) Core(TM) i7-1068NG7 CPU @ 2.30GHz
|
- **环境**: Darwin / Apple M3 Max
|
||||||
- **Excel.SetData**: 写入 100 行对象数据。
|
- **Excel.SetData**: 写入 100 行对象数据。
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@ -17,3 +22,4 @@ BenchmarkExcel_SetData-8 712 1673885 ns/op
|
|||||||
## 基础设施对齐
|
## 基础设施对齐
|
||||||
- 全面使用 `apigo.cc/go/cast` 替代原生类型转换。
|
- 全面使用 `apigo.cc/go/cast` 替代原生类型转换。
|
||||||
- 移除了所有原生 `os` 和 `strconv` 依赖。
|
- 移除了所有原生 `os` 和 `strconv` 依赖。
|
||||||
|
- Docx 提取原生集成,不再强制依赖第三方 binary 提取工具。
|
||||||
|
|||||||
101
csv.go
Normal file
101
csv.go
Normal 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())
|
||||||
|
}
|
||||||
12
document.go
12
document.go
@ -28,6 +28,12 @@ func Open(filename string, password ...string) (Document, error) {
|
|||||||
return OpenPptx(filename)
|
return OpenPptx(filename)
|
||||||
case ".pdf":
|
case ".pdf":
|
||||||
return OpenPDF(filename)
|
return OpenPDF(filename)
|
||||||
|
case ".graph":
|
||||||
|
return OpenGraph(filename)
|
||||||
|
case ".csv":
|
||||||
|
return OpenCSV(filename)
|
||||||
|
case ".md", ".markdown", ".txt":
|
||||||
|
return OpenMarkdown(filename)
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unsupported file type: %s", ext)
|
return nil, fmt.Errorf("unsupported file type: %s", ext)
|
||||||
}
|
}
|
||||||
@ -42,6 +48,12 @@ func Create(ext string) (Document, error) {
|
|||||||
case "docx":
|
case "docx":
|
||||||
// TODO: 支持创建 Word
|
// TODO: 支持创建 Word
|
||||||
return nil, fmt.Errorf("creating docx not supported yet")
|
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:
|
default:
|
||||||
return nil, fmt.Errorf("unsupported creation type: %s", ext)
|
return nil, fmt.Errorf("unsupported creation type: %s", ext)
|
||||||
}
|
}
|
||||||
|
|||||||
97
docx.go
97
docx.go
@ -1,8 +1,11 @@
|
|||||||
package document
|
package document
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"encoding/xml"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"apigo.cc/go/cast"
|
"apigo.cc/go/cast"
|
||||||
"apigo.cc/go/file"
|
"apigo.cc/go/file"
|
||||||
@ -26,15 +29,99 @@ func OpenDocx(filename string) (*Docx, error) {
|
|||||||
Metadata: make(map[string]any),
|
Metadata: make(map[string]any),
|
||||||
}
|
}
|
||||||
|
|
||||||
dp, err := docxtotext.Open(filename)
|
// 尝试提取层级结构的 Markdown
|
||||||
if err == nil {
|
md, err := d.extractMarkdown(filename)
|
||||||
defer dp.Close()
|
if err == nil && md != "" {
|
||||||
d.Content, _ = dp.ExtractTexts()
|
d.Content = md
|
||||||
|
} else {
|
||||||
|
// 回退到纯文本提取
|
||||||
|
dp, err := docxtotext.Open(filename)
|
||||||
|
if err == nil {
|
||||||
|
defer dp.Close()
|
||||||
|
d.Content, _ = dp.ExtractTexts()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return d, nil
|
return d, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// extractMarkdown 尝试从 docx 的 XML 中提取带标题的 Markdown。
|
||||||
|
func (d *Docx) extractMarkdown(filename string) (string, error) {
|
||||||
|
r, err := zip.OpenReader(filename)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer r.Close()
|
||||||
|
|
||||||
|
var docXML io.ReadCloser
|
||||||
|
for _, f := range r.File {
|
||||||
|
if f.Name == "word/document.xml" {
|
||||||
|
docXML, err = f.Open()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if docXML == nil {
|
||||||
|
return "", fmt.Errorf("word/document.xml not found")
|
||||||
|
}
|
||||||
|
defer docXML.Close()
|
||||||
|
|
||||||
|
decoder := xml.NewDecoder(docXML)
|
||||||
|
var sb strings.Builder
|
||||||
|
var inT bool
|
||||||
|
var currentStyle string
|
||||||
|
|
||||||
|
for {
|
||||||
|
t, err := decoder.Token()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch se := t.(type) {
|
||||||
|
case xml.StartElement:
|
||||||
|
if se.Name.Local == "p" {
|
||||||
|
currentStyle = ""
|
||||||
|
} else if se.Name.Local == "pStyle" {
|
||||||
|
for _, attr := range se.Attr {
|
||||||
|
if attr.Name.Local == "val" {
|
||||||
|
currentStyle = attr.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if se.Name.Local == "t" {
|
||||||
|
inT = true
|
||||||
|
}
|
||||||
|
case xml.EndElement:
|
||||||
|
if se.Name.Local == "p" {
|
||||||
|
sb.WriteString("\n")
|
||||||
|
} else if se.Name.Local == "t" {
|
||||||
|
inT = false
|
||||||
|
}
|
||||||
|
case xml.CharData:
|
||||||
|
if inT {
|
||||||
|
text := string(se)
|
||||||
|
if strings.Contains(strings.ToLower(currentStyle), "heading") {
|
||||||
|
level := "1"
|
||||||
|
if len(currentStyle) > 7 {
|
||||||
|
level = currentStyle[7:]
|
||||||
|
}
|
||||||
|
l := cast.To[int](level)
|
||||||
|
if l == 0 {
|
||||||
|
l = 1
|
||||||
|
}
|
||||||
|
prefix := strings.Repeat("#", l)
|
||||||
|
sb.WriteString("\n" + prefix + " " + text + "\n")
|
||||||
|
} else {
|
||||||
|
sb.WriteString(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSpace(sb.String()), nil
|
||||||
|
}
|
||||||
|
|
||||||
// ToJSON 返回包含元数据和内容的 JSON 字符串。
|
// ToJSON 返回包含元数据和内容的 JSON 字符串。
|
||||||
func (d *Docx) ToJSON() string {
|
func (d *Docx) ToJSON() string {
|
||||||
res, _ := cast.ToJSON(map[string]any{
|
res, _ := cast.ToJSON(map[string]any{
|
||||||
@ -55,8 +142,6 @@ func (d *Docx) Save(filename ...string) error {
|
|||||||
if len(filename) > 0 && filename[0] != "" {
|
if len(filename) > 0 && filename[0] != "" {
|
||||||
path = filename[0]
|
path = filename[0]
|
||||||
}
|
}
|
||||||
// 如果是原格式保存,目前仅作为文本保存或保持原样
|
|
||||||
// 复杂的 Docx 写入暂未集成
|
|
||||||
return file.Write(path, d.Content)
|
return file.Write(path, d.Content)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
45
excel.go
45
excel.go
@ -72,35 +72,46 @@ func (xls *Excel) ToJSON() string {
|
|||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToMarkdown 将第一个工作表的数据转换为 Markdown 表格。
|
// ToMarkdown 将所有工作表的数据转换为 Markdown 格式。
|
||||||
func (xls *Excel) ToMarkdown() string {
|
func (xls *Excel) ToMarkdown() string {
|
||||||
sheets := xls.Sheets()
|
sheets := xls.Sheets()
|
||||||
if len(sheets) == 0 {
|
if len(sheets) == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
rows, _ := xls.Get(sheets[0], "A1", "")
|
|
||||||
if len(rows) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
for i, row := range rows {
|
for _, sheetName := range sheets {
|
||||||
sb.WriteString("| ")
|
rows, _ := xls.Get(sheetName, "A1", "")
|
||||||
for _, col := range row {
|
if len(rows) == 0 {
|
||||||
sb.WriteString(cast.To[string](col))
|
continue
|
||||||
sb.WriteString(" | ")
|
|
||||||
}
|
}
|
||||||
sb.WriteString("\n")
|
|
||||||
if i == 0 {
|
if len(sheets) > 1 {
|
||||||
// 分隔线
|
sb.WriteString("## Sheet: " + sheetName + "\n\n")
|
||||||
sb.WriteString("|")
|
}
|
||||||
for range row {
|
|
||||||
sb.WriteString(" --- |")
|
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")
|
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())
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 基础操作方法 ---
|
// --- 基础操作方法 ---
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"apigo.cc/go/cast"
|
||||||
"apigo.cc/go/file"
|
"apigo.cc/go/file"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -43,7 +44,7 @@ func TestExcel_Basic(t *testing.T) {
|
|||||||
t.Errorf("Unexpected data length: %v", data)
|
t.Errorf("Unexpected data length: %v", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
if castTo[string](data[1][0]) != "Alice" {
|
if cast.To[string](data[1][0]) != "Alice" {
|
||||||
t.Errorf("Expected Alice, got %v", data[1][0])
|
t.Errorf("Expected Alice, got %v", data[1][0])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -83,7 +84,7 @@ func TestExcel_Data(t *testing.T) {
|
|||||||
t.Errorf("Expected 2 rows, got %d", len(readData))
|
t.Errorf("Expected 2 rows, got %d", len(readData))
|
||||||
}
|
}
|
||||||
|
|
||||||
if castTo[string](readData[0]["Value"]) != "A" {
|
if cast.To[string](readData[0]["Value"]) != "A" {
|
||||||
t.Errorf("Expected A, got %v", readData[0]["Value"])
|
t.Errorf("Expected A, got %v", readData[0]["Value"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -156,11 +157,3 @@ func BenchmarkExcel_SetData(b *testing.B) {
|
|||||||
_ = xls.SetData("Bench", data, "A1", "")
|
_ = xls.SetData("Bench", data, "A1", "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func castTo[T any](v any) T {
|
|
||||||
var res T
|
|
||||||
if val, ok := v.(T); ok {
|
|
||||||
return val
|
|
||||||
}
|
|
||||||
return res
|
|
||||||
}
|
|
||||||
|
|||||||
107
graph.go
Normal file
107
graph.go
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
package document
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"apigo.cc/go/cast"
|
||||||
|
"apigo.cc/go/file"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Node 代表文档中的一个节点,可以是一个场景、一个角色或一个知识点。
|
||||||
|
type Node struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
Meta map[string]any `json:"meta,omitempty"`
|
||||||
|
Links []string `json:"links,omitempty"` // 与其他节点的关联 (ID 列表)
|
||||||
|
Parents []string `json:"parents,omitempty"` // 父节点 (用于层级结构)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Graph 是一种具有关联关系的文档,适用于小说大纲、策划分镜、思维导图等场景。
|
||||||
|
type Graph struct {
|
||||||
|
filename string
|
||||||
|
Title string `json:"title"`
|
||||||
|
Nodes map[string]*Node `json:"nodes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewGraph 创建一个新的关系型文档。
|
||||||
|
func NewGraph() *Graph {
|
||||||
|
return &Graph{
|
||||||
|
Nodes: make(map[string]*Node),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddNode 添加或更新一个节点。
|
||||||
|
func (g *Graph) AddNode(n *Node) {
|
||||||
|
if n.ID == "" {
|
||||||
|
n.ID = cast.To[string](len(g.Nodes) + 1)
|
||||||
|
}
|
||||||
|
g.Nodes[n.ID] = n
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToJSON 返回文档的结构化 JSON 表示。
|
||||||
|
func (g *Graph) ToJSON() string {
|
||||||
|
res, _ := cast.ToJSON(g)
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToMarkdown 将关系型文档转换为带有 Mermaid 图表的 Markdown。
|
||||||
|
func (g *Graph) ToMarkdown() string {
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("# " + g.Title + "\n\n")
|
||||||
|
|
||||||
|
// 生成 Mermaid 关系图
|
||||||
|
sb.WriteString("```mermaid\ngraph TD\n")
|
||||||
|
for id, node := range g.Nodes {
|
||||||
|
label := node.Title
|
||||||
|
if label == "" {
|
||||||
|
label = id
|
||||||
|
}
|
||||||
|
// 节点样式根据类型变化
|
||||||
|
sb.WriteString(fmt.Sprintf(" %s[\"%s\"]\n", id, label))
|
||||||
|
for _, link := range node.Links {
|
||||||
|
sb.WriteString(fmt.Sprintf(" %s --> %s\n", id, link))
|
||||||
|
}
|
||||||
|
for _, parent := range node.Parents {
|
||||||
|
sb.WriteString(fmt.Sprintf(" %s --- %s\n", parent, id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.WriteString("```\n\n")
|
||||||
|
|
||||||
|
// 生成详细内容
|
||||||
|
for _, node := range g.Nodes {
|
||||||
|
sb.WriteString("## " + node.Title + " (" + node.ID + ")\n")
|
||||||
|
if node.Type != "" {
|
||||||
|
sb.WriteString("> Type: " + node.Type + "\n\n")
|
||||||
|
}
|
||||||
|
sb.WriteString(node.Content + "\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save 将文档保存为 JSON 文件。
|
||||||
|
func (g *Graph) Save(filename ...string) error {
|
||||||
|
path := g.filename
|
||||||
|
if len(filename) > 0 && filename[0] != "" {
|
||||||
|
path = filename[0]
|
||||||
|
}
|
||||||
|
if path == "" {
|
||||||
|
return fmt.Errorf("no filename specified")
|
||||||
|
}
|
||||||
|
return file.Write(path, g.ToJSON())
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenGraph 从 JSON 文件加载关系型文档。如果文件不存在,则返回一个新的空白文档。
|
||||||
|
func OpenGraph(filename string) (*Graph, error) {
|
||||||
|
g := NewGraph()
|
||||||
|
g.filename = filename
|
||||||
|
if file.Exists(filename) {
|
||||||
|
if err := file.UnmarshalFile(filename, g); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return g, nil
|
||||||
|
}
|
||||||
80
graph_test.go
Normal file
80
graph_test.go
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
package document
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"apigo.cc/go/file"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGraph_Basic(t *testing.T) {
|
||||||
|
filename := "test.graph"
|
||||||
|
defer file.Remove(filename)
|
||||||
|
|
||||||
|
g := NewGraph()
|
||||||
|
g.Title = "Novel Plot"
|
||||||
|
|
||||||
|
g.AddNode(&Node{
|
||||||
|
ID: "1",
|
||||||
|
Title: "Chapter 1",
|
||||||
|
Content: "Alice meets Bob.",
|
||||||
|
Type: "scene",
|
||||||
|
Links: []string{"2"},
|
||||||
|
})
|
||||||
|
|
||||||
|
g.AddNode(&Node{
|
||||||
|
ID: "2",
|
||||||
|
Title: "Chapter 2",
|
||||||
|
Content: "They go to the park.",
|
||||||
|
Type: "scene",
|
||||||
|
Parents: []string{"1"},
|
||||||
|
})
|
||||||
|
|
||||||
|
err := g.Save(filename)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Save failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
g2, err := OpenGraph(filename)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("OpenGraph failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if g2.Title != "Novel Plot" {
|
||||||
|
t.Errorf("Expected Title 'Novel Plot', got '%s'", g2.Title)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(g2.Nodes) != 2 {
|
||||||
|
t.Errorf("Expected 2 nodes, got %d", len(g2.Nodes))
|
||||||
|
}
|
||||||
|
|
||||||
|
md := g2.ToMarkdown()
|
||||||
|
if !strings.Contains(md, "mermaid") {
|
||||||
|
t.Errorf("Markdown should contain mermaid diagram")
|
||||||
|
}
|
||||||
|
if !strings.Contains(md, "Chapter 1") {
|
||||||
|
t.Errorf("Markdown should contain Chapter 1")
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonStr := g2.ToJSON()
|
||||||
|
if !strings.Contains(jsonStr, "Novel Plot") {
|
||||||
|
t.Errorf("JSON should contain Title")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGraph_Unified(t *testing.T) {
|
||||||
|
filename := "unified.graph"
|
||||||
|
defer file.Remove(filename)
|
||||||
|
|
||||||
|
doc, _ := Open(filename)
|
||||||
|
if g, ok := doc.(*Graph); ok {
|
||||||
|
g.Title = "Unified Test"
|
||||||
|
g.AddNode(&Node{Title: "Root"})
|
||||||
|
doc.Save()
|
||||||
|
}
|
||||||
|
|
||||||
|
doc2, _ := Open(filename)
|
||||||
|
if doc2.ToJSON() == "" {
|
||||||
|
t.Errorf("ToJSON failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
52
markdown.go
Normal file
52
markdown.go
Normal 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
54
misc_test.go
Normal 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
23
pdf.go
@ -1,9 +1,7 @@
|
|||||||
package document
|
package document
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"apigo.cc/go/cast"
|
"apigo.cc/go/cast"
|
||||||
@ -42,11 +40,24 @@ func OpenPDF(filename string) (*PDF, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var b bytes.Buffer
|
var sb strings.Builder
|
||||||
if t, err := f.GetPlainText(); err == nil {
|
for i := 1; i <= f.NumPage(); i++ {
|
||||||
io.Copy(&b, t)
|
p_ := f.Page(i)
|
||||||
p.Content = b.String()
|
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
|
return p, nil
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user