Files
Agent-System-Backend/internal/ingest/ooxml.go
T
weichen 727f3702d0
ci / vet-and-test (pull_request) Successful in 46s
Expose builtin document parsing through the host handle
Why: downstream parsers are unreachable via extension.Host and the base lacked xlsx/pptx parsers; MS1 needs Excel and PPT ingest (EXT-5 request, documents proposal 2026-10-06-ext-5-host-parse-document).

Evidence: local GOWORK=off go test ./... all 29 packages pass incl. new ooxml_test (hand-built parts), ooxml_python_test (openpyxl/python-pptx round-trip), inproc owner-scope and limit tests, xlsx upload e2e; go vet clean; recorded as SELF on the Dev task card, PR CI to confirm.

Impact: Host gains ParseDocument (pure compute; errors only on ctx cancel or >100MiB input); builtin:xlsx (sheet order via workbook.xml rels, rich-text shared strings, formula cache, 1900/1904 dates, sparse cells) and builtin:pptx (sldIdLst order, titles, group shapes, AlternateContent Choice, tables, notes) added with 200k-cell/64MiB-part caps; legacy doc/xls/ppt get an actionable hint; ParsersDescription gains xlsx/pptx and the missing text ext; surface snapshot now records exported interface methods; docx part reads share the 64MiB cap.

Refs: documents 2026-10-06-ext-5-host-parse-document.md; EXT-1 host.go precedent.
2026-10-06 14:59:38 +00:00

141 lines
3.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package ingest
// OOXML 公共件:zip 部件读取(解压上限防 zip 炸弹)、关系部件解析、
// r:id 属性与相对路径归一。docx/xlsx/pptx 解析器共用;纯标准库。
import (
"archive/zip"
"bytes"
"encoding/xml"
"fmt"
"io"
"path"
"strings"
)
// ooxmlPartLimit 单个 zip 部件的解压上限(测试可覆盖)。
var ooxmlPartLimit int64 = 64 << 20
func openZip(content []byte) (*zip.Reader, error) {
return zip.NewReader(bytes.NewReader(content), int64(len(content)))
}
// zipPart 按名称读取部件;解压超过 ooxmlPartLimit 视为异常拒绝。
func zipPart(zr *zip.Reader, name string) ([]byte, error) {
for _, f := range zr.File {
if f.Name != name {
continue
}
rc, err := f.Open()
if err != nil {
return nil, err
}
b, err := io.ReadAll(io.LimitReader(rc, ooxmlPartLimit+1))
rc.Close()
if err != nil {
return nil, err
}
if int64(len(b)) > ooxmlPartLimit {
return nil, fmt.Errorf("part %s exceeds %d bytes decompression limit", name, ooxmlPartLimit)
}
return b, nil
}
return nil, fmt.Errorf("part %s not found", name)
}
// relsPath 计算部件对应的关系部件名(xl/workbook.xml → xl/_rels/workbook.xml.rels)。
func relsPath(part string) string {
dir, base := path.Split(part)
return dir + "_rels/" + base + ".rels"
}
type ooxmlRel struct {
ID, Target, Type string
}
// parseRels 解析部件的关系表;无关系部件返回空表(不是错误)。
func parseRels(zr *zip.Reader, part string) (map[string]ooxmlRel, error) {
data, err := zipPart(zr, relsPath(part))
if err != nil {
return map[string]ooxmlRel{}, nil
}
dec := xml.NewDecoder(bytes.NewReader(data))
out := map[string]ooxmlRel{}
for {
tok, err := dec.Token()
if err == io.EOF {
break
}
if err != nil {
return nil, err
}
st, ok := tok.(xml.StartElement)
if !ok || st.Name.Local != "Relationship" {
continue
}
r := ooxmlRel{}
for _, a := range st.Attr {
switch a.Name.Local {
case "Id":
r.ID = a.Value
case "Target":
r.Target = a.Value
case "Type":
r.Type = a.Value
}
}
if r.ID != "" {
out[r.ID] = r
}
}
return out, nil
}
// resolveRelTarget 把关系 target 解析为 zip 部件名:绝对 target(/xl/…)去
// 前导斜杠;相对 target 相对 partDir;归一化 ../ 与 ./ 段。
func resolveRelTarget(partDir, target string) string {
t := target
if !strings.HasPrefix(target, "/") {
t = partDir + "/" + target
}
var seg []string
for _, s := range strings.Split(t, "/") {
switch s {
case "", ".":
case "..":
if len(seg) > 0 {
seg = seg[:len(seg)-1]
}
default:
seg = append(seg, s)
}
}
return strings.Join(seg, "/")
}
// ridAttr 取 r:id:局部名 id 且带命名空间前缀(Transitional 与 Strict OOXML
// 的关系引用都满足该形态;无前缀的 id 是别的编号,不误取)。
func ridAttr(st xml.StartElement) string {
for _, a := range st.Attr {
if a.Name.Local == "id" && a.Name.Space != "" {
return a.Value
}
}
return ""
}
// attrLocal 取无命名空间前缀的属性值。
func attrLocal(st xml.StartElement, name string) string {
for _, a := range st.Attr {
if a.Name.Local == name && a.Name.Space == "" {
return a.Value
}
}
return ""
}
// relIsType 判断关系类型是否以标准关系类型后缀结尾(/worksheet、/slide 等)。
func relIsType(rel ooxmlRel, suffix string) bool {
return strings.HasSuffix(rel.Type, suffix)
}