94 lines
2.6 KiB
Go
94 lines
2.6 KiB
Go
package kb
|
||
|
||
import (
|
||
"archive/zip"
|
||
"bytes"
|
||
"encoding/xml"
|
||
"fmt"
|
||
"io"
|
||
"strings"
|
||
)
|
||
|
||
// ========================================================================
|
||
// DOCX 解析器:从 Word (.docx) 字节流抽取纯文本
|
||
// ========================================================================
|
||
// 选型说明:
|
||
// - docx 本质是 zip 包,正文全部在 word/document.xml 里,
|
||
// 用标准库 archive/zip + encoding/xml 流式解析即可,零第三方依赖
|
||
// - 不用 unioffice(AGPL 许可证,商用需付费授权)
|
||
// - 老版二进制 .doc 是 OLE 复合文档格式,纯 Go 解析成本极高,
|
||
// 明确不支持,提示用户用 Word 另存为 .docx
|
||
// ========================================================================
|
||
|
||
// extractDocxText 抽取 docx 正文文本
|
||
//
|
||
// 解析规则(只认元素局部名,忽略 w: 命名空间前缀):
|
||
// <w:t> 文本节点,累加内容
|
||
// <w:p> 段落结束时补双换行(配合 ChunkPlainText 按双换行切段)
|
||
// <w:br> 手动换行 → \n
|
||
// <w:tab> 制表符 → \t
|
||
//
|
||
// 表格(w:tbl)里的单元格内容也是 w:p 段落,天然会被上面的规则覆盖,
|
||
// 不需要单独处理表格结构
|
||
func extractDocxText(data []byte) (string, error) {
|
||
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||
if err != nil {
|
||
return "", fmt.Errorf("kb: 打开 docx 失败(若为老版 .doc 请用 Word 另存为 .docx): %w", err)
|
||
}
|
||
|
||
// 定位正文文件 word/document.xml
|
||
var docFile *zip.File
|
||
for _, f := range zr.File {
|
||
if f.Name == "word/document.xml" {
|
||
docFile = f
|
||
break
|
||
}
|
||
}
|
||
if docFile == nil {
|
||
return "", fmt.Errorf("kb: docx 中找不到 word/document.xml,不是有效的 Word 文档")
|
||
}
|
||
|
||
rc, err := docFile.Open()
|
||
if err != nil {
|
||
return "", fmt.Errorf("kb: 读取 docx 正文失败: %w", err)
|
||
}
|
||
defer rc.Close()
|
||
|
||
dec := xml.NewDecoder(rc)
|
||
var sb strings.Builder
|
||
inText := false // 是否处于 <w:t> 内部(只收集 w:t 里的字符,跳过样式等噪音)
|
||
for {
|
||
tok, terr := dec.Token()
|
||
if terr == io.EOF {
|
||
break
|
||
}
|
||
if terr != nil {
|
||
return "", fmt.Errorf("kb: 解析 docx XML 失败: %w", terr)
|
||
}
|
||
switch t := tok.(type) {
|
||
case xml.StartElement:
|
||
switch t.Name.Local {
|
||
case "t":
|
||
inText = true
|
||
case "br", "cr":
|
||
sb.WriteString("\n")
|
||
case "tab":
|
||
sb.WriteString("\t")
|
||
}
|
||
case xml.EndElement:
|
||
switch t.Name.Local {
|
||
case "t":
|
||
inText = false
|
||
case "p":
|
||
// 段落边界 → 空行,供 ChunkPlainText 按段落切分
|
||
sb.WriteString("\n\n")
|
||
}
|
||
case xml.CharData:
|
||
if inText {
|
||
sb.Write(t)
|
||
}
|
||
}
|
||
}
|
||
return strings.TrimSpace(sb.String()), nil
|
||
}
|