ci / vet-and-test (pull_request) Successful in 46s
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.
141 lines
3.4 KiB
Go
141 lines
3.4 KiB
Go
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)
|
||
}
|