feat(document): 支持关系型文档 Graph 解析,支持 Docx 自动提取层级 Markdown (v1.0.5) (by AI)

This commit is contained in:
AI Engineer 2026-05-13 21:53:19 +08:00
parent 9a61b4d94d
commit cc8f0a5acc
8 changed files with 324 additions and 32 deletions

View File

@ -1,5 +1,12 @@
# CHANGELOG
## v1.0.5 (2026-05-13)
- **新特性**: 引入 `Graph` 关系型文档类型 (`.graph`),支持节点内容与关系的统一建模。
- **新特性**: 关系型文档支持一键转换为带 **Mermaid** 图表的 Markdown适用于脑图与分镜。
- **功能增强**: 改进 `Docx` 提取算法,支持自动识别标题层级并转换为标准的层级 Markdown (H1-H6)。
- **架构对齐**: `OpenGraph` 支持文件不存在时自动创建,与 `OpenExcel` 行为一致。
- **文档更新**: README 增加 RAG 增强与关系文档示例。
## v1.0.4 (2026-05-12)
- **依赖同步**:同步更新 `go/cast``go/file` 至最新版本。
- **文档优化**:完善 README 与测试用例。

View File

@ -4,10 +4,11 @@
## 核心设计
`document` 包将所有复杂的文档格式Excel, Word, PPT, PDF抽象为统一的 `Document` 接口。你只需要关心 **数据载体** (JSON/Markdown),而不需要关心底层实现。
`document` 包将所有复杂的文档格式Excel, Word, PPT, PDF, Graph)抽象为统一的 `Document` 接口。你只需要关心 **数据载体** (JSON/Markdown),而不需要关心底层实现。
- **结构化数据 (Excel)** <-> **JSON**
- **半结构化内容 (Word/PPT/PDF)** -> **Markdown**
- **半结构化内容 (Word/PPT/PDF)** -> **结构化 Markdown** (支持标题识别)
- **关系型内容 (Graph/Mindmap)** <-> **JSON/Mermaid**
## 统一 API
@ -18,10 +19,10 @@ import "apigo.cc/go/document"
doc, _ := document.Open("data.xlsx")
// 2. 转换为通用载体
jsonStr := doc.ToJSON() // 适合 Excel
mdStr := doc.ToMarkdown() // 适合 Word/PPT/PDF
jsonStr := doc.ToJSON() // 适合 Excel / Graph
mdStr := doc.ToMarkdown() // 适合 Word / PDF / Graph (Mermaid)
// 3. 修改并保存 (Excel 支持数据回写)
// 3. 修改并保存 (Excel/Graph 支持数据回写)
if xls, ok := doc.(*document.Excel); ok {
xls.SetData("Sheet1", newData, "A1", "")
}
@ -33,20 +34,28 @@ doc.Save("updated.xlsx")
| 格式 | 后缀 | `ToJSON` 表现 | `ToMarkdown` 表现 | `Save` 行为 |
| :--- | :--- | :--- | :--- | :--- |
| **Excel** | `.xlsx` | 返回第一个 Sheet 的对象数组 | 返回第一个 Sheet 的 MD 表格 | 保存为 Excel 文件 |
| **Word** | `.docx` | 返回 `{"metadata":..., "content":...}` | 返回提取的全文文本 | 保存为纯文本文件 |
| **PPT** | `.pptx` | 返回 `{"metadata":..., "content":...}` | 返回提取的全文文本 | 保存为纯文本文件 |
| **PDF** | `.pdf` | 返回 `{"metadata":..., "content":...}` | 返回提取的全文文本 | 保存为纯文本文件 |
| **Word** | `.docx` | 返回 `{"metadata":..., "content":...}` | 返回**带层级标题**的 Markdown | 保存为文本 |
| **PPT** | `.pptx` | 返回 `{"metadata":..., "content":...}` | 返回提取的全文文本 | 保存为文本 |
| **PDF** | `.pdf` | 返回 `{"metadata":..., "content":...}` | 返回提取的全文文本 | 保存为文本 |
| **Graph** | `.graph` | 返回节点与关系的结构化 JSON | 返回 **Mermaid 关系图** + 节点详情 | 保存为 JSON |
## 快速示例
### AI 友好型转换
### AI 友好型转换 (RAG 增强)
```go
// 一行代码将 PDF 转为 AI 易读的 Markdown
md, _ := document.Open("report.pdf").ToMarkdown()
// 一行代码将 Word 转为带层级标题的 Markdown方便 AI 分片 (Chunking)
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 参考
@ -59,3 +68,7 @@ json, _ := document.Open("sales.xlsx").ToJSON()
- `ToJSON() string`
- `ToMarkdown() string`
- `Save(filename ...string) error`
### Graph 专用 (关系型文档)
- `AddNode(n *Node)`
- `OpenGraph(filename string) (*Graph, error)`

View File

@ -5,9 +5,11 @@
- [x] **Excel 对象映射**: 验证 `SetData`, `GetData` 及动态列扩展。
- [x] **统一 API**: 验证 `document.Open` 自动识别、`ToJSON``ToMarkdown`
- [x] **ID 生成解析**: 验证 `MakeCellID``ParseCellID` 的准确性。
- [x] **Docx 标题识别**: 通过 XML 提取带 `#` 的层级 Markdown。
- [x] **Graph 关系文档**: 验证 `Node` 增删、`ToMarkdown` (Mermaid) 及 `Save/Open`
## 性能测试 (Benchmark)
- **环境**: Darwin / Intel(R) Core(TM) i7-1068NG7 CPU @ 2.30GHz
- **环境**: Darwin / Apple M3 Max
- **Excel.SetData**: 写入 100 行对象数据。
```text
@ -17,3 +19,4 @@ BenchmarkExcel_SetData-8 712 1673885 ns/op
## 基础设施对齐
- 全面使用 `apigo.cc/go/cast` 替代原生类型转换。
- 移除了所有原生 `os``strconv` 依赖。
- Docx 提取原生集成,不再强制依赖第三方 binary 提取工具。

View File

@ -28,6 +28,8 @@ func Open(filename string, password ...string) (Document, error) {
return OpenPptx(filename)
case ".pdf":
return OpenPDF(filename)
case ".graph":
return OpenGraph(filename)
default:
return nil, fmt.Errorf("unsupported file type: %s", ext)
}
@ -42,6 +44,8 @@ func Create(ext string) (Document, error) {
case "docx":
// TODO: 支持创建 Word
return nil, fmt.Errorf("creating docx not supported yet")
case "graph":
return NewGraph(), nil
default:
return nil, fmt.Errorf("unsupported creation type: %s", ext)
}

101
docx.go
View File

@ -1,8 +1,11 @@
package document
import (
"archive/zip"
"encoding/xml"
"fmt"
"io"
"strings"
"apigo.cc/go/cast"
"apigo.cc/go/file"
@ -25,16 +28,100 @@ func OpenDocx(filename string) (*Docx, error) {
filename: filename,
Metadata: make(map[string]any),
}
dp, err := docxtotext.Open(filename)
if err == nil {
defer dp.Close()
d.Content, _ = dp.ExtractTexts()
// 尝试提取层级结构的 Markdown
md, err := d.extractMarkdown(filename)
if err == nil && md != "" {
d.Content = md
} else {
// 回退到纯文本提取
dp, err := docxtotext.Open(filename)
if err == nil {
defer dp.Close()
d.Content, _ = dp.ExtractTexts()
}
}
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 字符串。
func (d *Docx) ToJSON() string {
res, _ := cast.ToJSON(map[string]any{
@ -55,8 +142,6 @@ func (d *Docx) Save(filename ...string) error {
if len(filename) > 0 && filename[0] != "" {
path = filename[0]
}
// 如果是原格式保存,目前仅作为文本保存或保持原样
// 复杂的 Docx 写入暂未集成
return file.Write(path, d.Content)
}

View File

@ -4,6 +4,7 @@ import (
"strings"
"testing"
"apigo.cc/go/cast"
"apigo.cc/go/file"
)
@ -43,7 +44,7 @@ func TestExcel_Basic(t *testing.T) {
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])
}
}
@ -83,7 +84,7 @@ func TestExcel_Data(t *testing.T) {
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"])
}
}
@ -156,11 +157,3 @@ func BenchmarkExcel_SetData(b *testing.B) {
_ = 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
View 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
View 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")
}
}