Files
Agent-System-Backend/internal/ingest/pptx.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

292 lines
7.2 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
// 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 ""
}