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.
292 lines
7.2 KiB
Go
292 lines
7.2 KiB
Go
package ingest
|
||
|
||
// pptx/pptm/ppsx(PresentationML)内置解析器。幻灯片顺序取 presentation.xml
|
||
// 的 sldIdLst;标题占位符(title/ctrTitle)产标题块,正文形状按段落输出
|
||
//(a:br 换行),组合形状经同一 token 走查天然递归;mc:AlternateContent 只取
|
||
// Choice 分支;graphicFrame 内的表格按 tr/tc 输出(gridSpan/hMerge 占位保列
|
||
// 对齐);备注页正文输出「备注:…」;页码/日期/页脚占位符跳过。图片与
|
||
// SmartArt 不解析。纯标准库。
|
||
|
||
import (
|
||
"archive/zip"
|
||
"bytes"
|
||
"encoding/xml"
|
||
"fmt"
|
||
"io"
|
||
"path"
|
||
"strconv"
|
||
"strings"
|
||
|
||
core "agent-system-core"
|
||
)
|
||
|
||
func parsePptx(content []byte) ([]core.DocBlock, []string) {
|
||
zr, err := openZip(content)
|
||
if err != nil {
|
||
return nil, []string{"pptx 解析失败:" + err.Error()}
|
||
}
|
||
slides, warns := pptxSlideOrder(zr)
|
||
if slides == nil && len(warns) > 0 {
|
||
return nil, warns
|
||
}
|
||
var blocks []core.DocBlock
|
||
for i, sp := range slides {
|
||
n := i + 1
|
||
data, err := zipPart(zr, sp)
|
||
if err != nil {
|
||
warns = append(warns, fmt.Sprintf("幻灯片 %d 读取失败:%v", n, err))
|
||
blocks = append(blocks, core.DocBlock{Type: core.BlockHeading, Level: 2, Text: fmt.Sprintf("幻灯片 %d", n)})
|
||
continue
|
||
}
|
||
title, paras, tables := pptxSlideBlocks(data)
|
||
heading := fmt.Sprintf("幻灯片 %d", n)
|
||
if title != "" {
|
||
heading += ":" + title
|
||
}
|
||
blocks = append(blocks, core.DocBlock{Type: core.BlockHeading, Level: 2, Text: heading})
|
||
blocks = append(blocks, paras...)
|
||
blocks = append(blocks, tables...)
|
||
if note := pptxSlideNotes(zr, sp); note != "" {
|
||
blocks = append(blocks, core.DocBlock{Type: core.BlockPara, Text: "备注:" + note})
|
||
}
|
||
}
|
||
if len(blocks) == 0 && len(warns) == 0 {
|
||
warns = append(warns, "pptx 无可解析的幻灯片内容")
|
||
}
|
||
return blocks, warns
|
||
}
|
||
|
||
// pptxSlideOrder 读 presentation.xml 的 sldIdLst → 幻灯片部件名(按顺序)。
|
||
func pptxSlideOrder(zr *zip.Reader) ([]string, []string) {
|
||
data, err := zipPart(zr, "ppt/presentation.xml")
|
||
if err != nil {
|
||
return nil, []string{"pptx 解析失败:" + err.Error()}
|
||
}
|
||
rels, err := parseRels(zr, "ppt/presentation.xml")
|
||
if err != nil {
|
||
return nil, []string{"pptx 解析失败:" + err.Error()}
|
||
}
|
||
dec := xml.NewDecoder(bytes.NewReader(data))
|
||
var out, warns []string
|
||
for {
|
||
tok, err := dec.Token()
|
||
if err == io.EOF {
|
||
break
|
||
}
|
||
if err != nil {
|
||
return out, append(warns, "presentation.xml 解码失败:"+err.Error())
|
||
}
|
||
st, ok := tok.(xml.StartElement)
|
||
if !ok || st.Name.Local != "sldId" {
|
||
continue
|
||
}
|
||
rel, ok := rels[ridAttr(st)]
|
||
if !ok || !relIsType(rel, "/slide") {
|
||
warns = append(warns, "幻灯片关系缺失或类型不符")
|
||
continue
|
||
}
|
||
out = append(out, resolveRelTarget("ppt", rel.Target))
|
||
}
|
||
return out, warns
|
||
}
|
||
|
||
// pptxSlideBlocks 解析一张幻灯片:标题、正文段落、表格块。
|
||
// 表格块统一附在段落后(图形框与正文的精确穿插对检索无价值)。
|
||
func pptxSlideBlocks(data []byte) (title string, paras []core.DocBlock, tables []core.DocBlock) {
|
||
dec := xml.NewDecoder(bytes.NewReader(data))
|
||
var stack []string
|
||
has := func(name string) bool {
|
||
for _, s := range stack {
|
||
if s == name {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
skip := 0 // mc:Fallback 子树深度
|
||
// 形状状态
|
||
spDepth := -1
|
||
phSkip := false
|
||
isTitle := false
|
||
var spParas []string
|
||
var para strings.Builder
|
||
paraOpen := false
|
||
// 文本收集目标
|
||
textInPara := false
|
||
textInCell := false
|
||
// 表格状态
|
||
var tblRows [][]string
|
||
var tblRow []string
|
||
var cellBuf strings.Builder
|
||
cellOpen := false
|
||
gridSpan := 1
|
||
cellHM := false
|
||
|
||
finishPara := func() {
|
||
if !paraOpen {
|
||
return
|
||
}
|
||
paraOpen = false
|
||
if s := strings.TrimRight(para.String(), "\n"); strings.TrimSpace(s) != "" {
|
||
spParas = append(spParas, strings.TrimSpace(s))
|
||
}
|
||
para.Reset()
|
||
}
|
||
|
||
for {
|
||
tok, err := dec.Token()
|
||
if err == io.EOF {
|
||
break
|
||
}
|
||
if err != nil {
|
||
return "", paras, tables
|
||
}
|
||
switch t := tok.(type) {
|
||
case xml.StartElement:
|
||
if skip > 0 {
|
||
skip++
|
||
continue
|
||
}
|
||
local := t.Name.Local
|
||
switch local {
|
||
case "Fallback":
|
||
if has("AlternateContent") {
|
||
skip = 1
|
||
continue
|
||
}
|
||
case "sp":
|
||
spDepth = len(stack)
|
||
phSkip, isTitle, spParas = false, false, nil
|
||
case "ph":
|
||
if spDepth >= 0 && len(stack) > spDepth {
|
||
switch attrLocal(t, "type") {
|
||
case "title", "ctrTitle":
|
||
isTitle = true
|
||
case "dt", "ftr", "sldNum":
|
||
phSkip = true
|
||
}
|
||
}
|
||
case "p":
|
||
if spDepth >= 0 && has("txBody") && !has("tbl") {
|
||
finishPara()
|
||
paraOpen = true
|
||
para.Reset()
|
||
} else if cellOpen {
|
||
if cellBuf.Len() > 0 {
|
||
cellBuf.WriteByte('\n')
|
||
}
|
||
}
|
||
case "br":
|
||
if paraOpen {
|
||
para.WriteByte('\n')
|
||
}
|
||
case "t":
|
||
if has("fld") {
|
||
break // 字段(页码/日期)不取
|
||
}
|
||
textInPara = paraOpen
|
||
textInCell = cellOpen && !paraOpen
|
||
case "tc":
|
||
if has("tbl") {
|
||
cellBuf.Reset()
|
||
cellOpen = true
|
||
gridSpan, _ = strconv.Atoi(attrLocal(t, "gridSpan"))
|
||
if gridSpan < 1 {
|
||
gridSpan = 1
|
||
}
|
||
hm := attrLocal(t, "hMerge")
|
||
cellHM = hm == "1" || strings.EqualFold(hm, "true")
|
||
}
|
||
}
|
||
stack = append(stack, local)
|
||
case xml.CharData:
|
||
if textInPara {
|
||
para.Write(t)
|
||
} else if textInCell {
|
||
cellBuf.Write(t)
|
||
}
|
||
case xml.EndElement:
|
||
if skip > 0 {
|
||
skip--
|
||
continue
|
||
}
|
||
if len(stack) > 0 {
|
||
stack = stack[:len(stack)-1]
|
||
}
|
||
switch t.Name.Local {
|
||
case "t":
|
||
textInPara, textInCell = false, false
|
||
case "p":
|
||
finishPara()
|
||
case "sp":
|
||
if spDepth >= 0 && len(stack) == spDepth {
|
||
if !phSkip && len(spParas) > 0 {
|
||
if isTitle && title == "" {
|
||
title = strings.Join(spParas, " ")
|
||
} else {
|
||
for _, p := range spParas {
|
||
paras = append(paras, core.DocBlock{Type: core.BlockPara, Text: p})
|
||
}
|
||
}
|
||
}
|
||
spDepth = -1
|
||
phSkip, isTitle, spParas = false, false, nil
|
||
}
|
||
case "tc":
|
||
if cellOpen {
|
||
text := strings.TrimSpace(cellBuf.String())
|
||
if cellHM {
|
||
text = "" // 横向合并的续格:占位保列对齐
|
||
}
|
||
tblRow = append(tblRow, text)
|
||
for i := 1; i < gridSpan; i++ {
|
||
tblRow = append(tblRow, "") // 合并占位保列对齐
|
||
}
|
||
cellOpen = false
|
||
}
|
||
case "tr":
|
||
if len(tblRow) > 0 {
|
||
for len(tblRow) > 0 && tblRow[len(tblRow)-1] == "" {
|
||
tblRow = tblRow[:len(tblRow)-1]
|
||
}
|
||
if len(tblRow) > 0 {
|
||
tblRows = append(tblRows, tblRow)
|
||
}
|
||
tblRow = nil
|
||
}
|
||
case "tbl":
|
||
if len(tblRows) > 0 {
|
||
tables = append(tables, core.DocBlock{Type: core.BlockTable, Rows: tblRows})
|
||
}
|
||
tblRows = nil
|
||
}
|
||
}
|
||
}
|
||
finishPara()
|
||
return title, paras, tables
|
||
}
|
||
|
||
// pptxSlideNotes 读幻灯片的备注页正文;无备注返回空串。
|
||
func pptxSlideNotes(zr *zip.Reader, slidePart string) string {
|
||
rels, err := parseRels(zr, slidePart)
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
for _, rel := range rels {
|
||
if !relIsType(rel, "/notesSlide") {
|
||
continue
|
||
}
|
||
data, err := zipPart(zr, resolveRelTarget(path.Dir(slidePart), rel.Target))
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
_, paras, _ := pptxSlideBlocks(data)
|
||
var lines []string
|
||
for _, p := range paras {
|
||
lines = append(lines, p.Text)
|
||
}
|
||
return strings.Join(lines, "\n")
|
||
}
|
||
return ""
|
||
}
|