ci / vet-and-test (pull_request) Successful in 58s
Why: A second broken pptx passed the shipped gate despite being unopenable in Office: parts were all present but presentation.xml and the master used an undeclared xmlns:r prefix, sldIdLst listed 12 slides while the rels file wired only one, and [Content_Types].xml overrode only slide1. The prior part-existence checks cannot see any of these; the generating instance also predated the first gate deployment. Evidence: The upgraded gate rejects the real broken file (undeclared xmlns:r) and 16 synthetic break cases spanning both incident classes, while passing the manually repaired package and kit output; python round-trip on the repaired deck extracts all 12 slides. Full-suite receipt follows on the task card. Impact: register_artifact now enforces strict namespace declarations across every XML part, bidirectional consistency between id-lists and relationships (presentation and masters), and per-part content-type overrides. Error messages name the broken reference. run_python prompt v3 adds that failed kit attempts must retry through the kit, never fall back to hand assembly. Refs: Dev task feature/pptx-gate-reference-integrity
580 lines
18 KiB
Go
580 lines
18 KiB
Go
// OOXML 交付物结构自检(登记口 L1):纯 Go 静态校验,零外部依赖。
|
||
// 背景:Agent 绕开 housestyle kit 用 zipfile 手拼 OOXML 会产出 Office 拒开的
|
||
// 残缺包。已知的两类事故形态:①部件整体缺失(缺母版/主题/docProps);②部件
|
||
// 齐全但引用断裂(根元素漏声明 xmlns:r、sldIdLst 与关系表不一致、
|
||
// [Content_Types] 缺 Override)——②能骗过"部件存在性"检查,Office 依旧打不开。
|
||
// 这里按「容器合法 + 必备部件 + 引用链完整 + 引用一致 + 内容类型覆盖」在登记前
|
||
// 拦截,失败信息直接指向 kit 重建路径。结构级评分(housestyle.lint / 终轮门禁)
|
||
// 不在本层:本层只保证「Office 打得开」这条底线。
|
||
package tools
|
||
|
||
import (
|
||
"archive/zip"
|
||
"bytes"
|
||
"encoding/xml"
|
||
"fmt"
|
||
"io"
|
||
"path"
|
||
"path/filepath"
|
||
"strings"
|
||
)
|
||
|
||
const (
|
||
nsPresentationML = "http://schemas.openxmlformats.org/presentationml/2006/main"
|
||
nsOfficeDocRel = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
|
||
ctPresentation = "application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"
|
||
ctSlide = "application/vnd.openxmlformats-officedocument.presentationml.slide+xml"
|
||
ctSlideLayout = "application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml"
|
||
ctSlideMaster = "application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml"
|
||
ctTheme = "application/vnd.openxmlformats-officedocument.theme+xml"
|
||
)
|
||
|
||
// ooxmlStructuralCheck 校验 name 指向的 Office 交付物是否具备可被 Office 打开
|
||
// 的最小 OPC 结构。不在管控扩展名内的文件直接放行。
|
||
func ooxmlStructuralCheck(name string, content []byte) error {
|
||
switch strings.ToLower(filepath.Ext(name)) {
|
||
case ".docx":
|
||
return ooxmlCheckParts(content, "word/document.xml")
|
||
case ".xlsx":
|
||
return ooxmlCheckParts(content, "xl/workbook.xml")
|
||
case ".pptx":
|
||
return ooxmlCheckPptx(content)
|
||
default:
|
||
return nil
|
||
}
|
||
}
|
||
|
||
// ooxmlCheckParts 校验容器与必备正文部件存在、XML 合法、命名空间前缀齐备
|
||
// (docx/xlsx 底线)。
|
||
func ooxmlCheckParts(content []byte, parts ...string) error {
|
||
zr, err := zip.NewReader(bytes.NewReader(content), int64(len(content)))
|
||
if err != nil {
|
||
return fmt.Errorf("不是有效的 OOXML 容器(%v)——Office 交付物必须由 housestyle kit 生成", err)
|
||
}
|
||
if err := ooxmlWellFormed(zr, "[Content_Types].xml"); err != nil {
|
||
return err
|
||
}
|
||
for _, part := range parts {
|
||
if err := ooxmlWellFormed(zr, part); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return ooxmlNamespaceStrict(zr)
|
||
}
|
||
|
||
// ooxmlCheckPptx 校验演示稿:引用链完整(slide→layout→master→theme)、
|
||
// sldIdLst/sldMasterIdLst 与关系表一致、[Content_Types] 覆盖全部结构部件。
|
||
func ooxmlCheckPptx(content []byte) error {
|
||
zr, err := zip.NewReader(bytes.NewReader(content), int64(len(content)))
|
||
if err != nil {
|
||
return fmt.Errorf("不是有效的 OOXML 容器(%v)——Office 交付物必须由 housestyle kit 生成", err)
|
||
}
|
||
for _, part := range []string{"[Content_Types].xml", "ppt/presentation.xml"} {
|
||
if err := ooxmlWellFormed(zr, part); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if err := ooxmlRequireElement(zr, "ppt/presentation.xml", "sldMasterIdLst"); err != nil {
|
||
return fmt.Errorf("presentation.xml 缺少 <sldMasterIdLst>(幻灯片母版列表)——母版与主题是必备结构")
|
||
}
|
||
// 严格命名空间先行:后续的 r:id 属性解析依赖前缀正确声明。
|
||
if err := ooxmlNamespaceStrict(zr); err != nil {
|
||
return err
|
||
}
|
||
presRels, err := ooxmlRels(zr, "ppt/presentation.xml")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
// sldIdLst / sldMasterIdLst 是幻灯片与母版的权威清单,必须与关系表一致。
|
||
masterIDs, err := ooxmlIdListOf(zr, "ppt/presentation.xml", "sldMasterIdLst", "sldMasterId")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
slideIDs, err := ooxmlIdListOf(zr, "ppt/presentation.xml", "sldIdLst", "sldId")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
relsByType := func(suffix string) map[string]string {
|
||
out := map[string]string{}
|
||
for _, rel := range presRels {
|
||
if strings.HasSuffix(rel.typ, suffix) {
|
||
out[rel.id] = ooxmlResolve("ppt/presentation.xml", rel.target)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
masterTargets, err := ooxmlConsistent(masterIDs, relsByType("/slideMaster"), "sldMasterIdLst", "slideMaster")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
slideTargets, err := ooxmlConsistent(slideIDs, relsByType("/slide"), "sldIdLst", "slide")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if len(masterTargets) == 0 {
|
||
return fmt.Errorf("presentation 未引用任何幻灯片母版(slideMaster)——母版与主题是必备结构")
|
||
}
|
||
if len(slideTargets) == 0 {
|
||
return fmt.Errorf("presentation 未引用任何幻灯片")
|
||
}
|
||
themes := map[string]bool{}
|
||
for _, master := range masterTargets {
|
||
if err := ooxmlMasterChain(zr, master, themes); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
masterSet := map[string]bool{}
|
||
for _, m := range masterTargets {
|
||
masterSet[m] = true
|
||
}
|
||
layouts := map[string]bool{}
|
||
for _, slide := range slideTargets {
|
||
if err := ooxmlWellFormed(zr, slide); err != nil {
|
||
return err
|
||
}
|
||
rels, err := ooxmlRels(zr, slide)
|
||
if err != nil {
|
||
return fmt.Errorf("幻灯片 %s:%v", slide, err)
|
||
}
|
||
layout := ""
|
||
for _, rel := range rels {
|
||
if strings.HasSuffix(rel.typ, "/slideLayout") {
|
||
layout = ooxmlResolve(slide, rel.target)
|
||
break
|
||
}
|
||
}
|
||
if layout == "" {
|
||
return fmt.Errorf("幻灯片 %s 没有指向 slideLayout 的关系——每张 slide 必须经关系表引用一个版式", slide)
|
||
}
|
||
if layouts[layout] {
|
||
continue
|
||
}
|
||
layouts[layout] = true
|
||
if err := ooxmlWellFormed(zr, layout); err != nil {
|
||
return err
|
||
}
|
||
if err := ooxmlLayoutMaster(zr, layout, masterSet); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
// [Content_Types].xml 必须对每个结构部件给出 Override;缺了会被 Office
|
||
// 当成未知类型拒开(真实事故:只写了 slide1 的 Override)。
|
||
overrides, err := ooxmlCTOverrides(zr)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
want := map[string]string{"ppt/presentation.xml": ctPresentation}
|
||
for _, t := range masterTargets {
|
||
want[t] = ctSlideMaster
|
||
}
|
||
for _, t := range slideTargets {
|
||
want[t] = ctSlide
|
||
}
|
||
for t := range layouts {
|
||
want[t] = ctSlideLayout
|
||
}
|
||
for t := range themes {
|
||
want[t] = ctTheme
|
||
}
|
||
for part, ct := range want {
|
||
got, ok := overrides["/"+part]
|
||
if !ok {
|
||
return fmt.Errorf("[Content_Types].xml 缺少 %s 的 Override(%s)——手拼包常见缺陷,请用 housestyle kit 重建", part, ct)
|
||
}
|
||
if got != ct {
|
||
return fmt.Errorf("[Content_Types].xml 中 %s 的内容类型不符:got %s want %s", part, got, ct)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ooxmlMasterChain 校验母版存在、声明版式列表且与关系表一致、引用存在的 theme。
|
||
func ooxmlMasterChain(zr *zip.Reader, master string, themes map[string]bool) error {
|
||
if err := ooxmlWellFormed(zr, master); err != nil {
|
||
return err
|
||
}
|
||
rels, err := ooxmlRels(zr, master)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := ooxmlRequireElement(zr, master, "sldLayoutIdLst"); err != nil {
|
||
return fmt.Errorf("母版 %s 缺少 <sldLayoutIdLst>(版式列表)——母版必须声明至少一个版式", master)
|
||
}
|
||
layoutIDs, err := ooxmlIdListOf(zr, master, "sldLayoutIdLst", "sldLayoutId")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
layoutTargets := map[string]string{}
|
||
for _, rel := range rels {
|
||
if strings.HasSuffix(rel.typ, "/slideLayout") {
|
||
layoutTargets[rel.id] = ooxmlResolve(master, rel.target)
|
||
}
|
||
}
|
||
resolved, err := ooxmlConsistent(layoutIDs, layoutTargets, "sldLayoutIdLst", "slideLayout")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
for _, target := range resolved {
|
||
if err := ooxmlWellFormed(zr, target); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
for _, rel := range rels {
|
||
if strings.HasSuffix(rel.typ, "/theme") {
|
||
theme := ooxmlResolve(master, rel.target)
|
||
if err := ooxmlWellFormed(zr, theme); err != nil {
|
||
return err
|
||
}
|
||
themes[theme] = true
|
||
return nil
|
||
}
|
||
}
|
||
return fmt.Errorf("母版 %s 没有指向 theme 的关系", master)
|
||
}
|
||
|
||
// ooxmlLayoutMaster 校验版式的关系表引用了存在的、且被 presentation 列出的
|
||
// slideMaster。
|
||
func ooxmlLayoutMaster(zr *zip.Reader, layout string, masters map[string]bool) error {
|
||
rels, err := ooxmlRels(zr, layout)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
for _, rel := range rels {
|
||
if strings.HasSuffix(rel.typ, "/slideMaster") {
|
||
master := ooxmlResolve(layout, rel.target)
|
||
if !masters[master] {
|
||
return fmt.Errorf("版式 %s 指向的母版 %s 未在 presentation 的 sldMasterIdLst 中声明", layout, master)
|
||
}
|
||
return nil
|
||
}
|
||
}
|
||
return fmt.Errorf("版式 %s 没有指向 slideMaster 的关系", layout)
|
||
}
|
||
|
||
// ooxmlIdListOf 提取 part 中 <listLocal> 列表内每个 <itemLocal> 的 r:id 属性。
|
||
func ooxmlIdListOf(zr *zip.Reader, part, listLocal, itemLocal string) ([]string, error) {
|
||
f, err := ooxmlPart(zr, part)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
rc, err := f.Open()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("部件 %s 无法读取(%v)", part, err)
|
||
}
|
||
defer rc.Close()
|
||
dec := xml.NewDecoder(rc)
|
||
var ids []string
|
||
inList := false
|
||
for {
|
||
tok, err := dec.Token()
|
||
if err == io.EOF {
|
||
break
|
||
}
|
||
if err != nil {
|
||
return nil, fmt.Errorf("部件 %s 不是合法 XML(%v)", part, err)
|
||
}
|
||
switch se := tok.(type) {
|
||
case xml.StartElement:
|
||
if se.Name.Space == nsPresentationML && se.Name.Local == listLocal {
|
||
inList = true
|
||
continue
|
||
}
|
||
if inList && se.Name.Space == nsPresentationML && se.Name.Local == itemLocal {
|
||
if rid, ok := ooxmlAttrNS(se, nsOfficeDocRel, "id"); ok {
|
||
ids = append(ids, rid)
|
||
} else {
|
||
return nil, fmt.Errorf("%s 的 <%s> 缺少 r:id 属性——引用链断裂,请用 housestyle kit 重建", listLocal, itemLocal)
|
||
}
|
||
}
|
||
case xml.EndElement:
|
||
if inList && se.Name.Space == nsPresentationML && se.Name.Local == listLocal {
|
||
inList = false
|
||
}
|
||
}
|
||
}
|
||
return ids, nil
|
||
}
|
||
|
||
// ooxmlConsistent 校验 ID 清单与关系表双向一致:清单里每个 r:id 都要命中
|
||
// 同类型关系,关系表也不得多出或缺失。返回按清单顺序解析出的目标部件。
|
||
func ooxmlConsistent(ids []string, targets map[string]string, listName, what string) ([]string, error) {
|
||
seen := map[string]bool{}
|
||
var out []string
|
||
for _, id := range ids {
|
||
if seen[id] {
|
||
return nil, fmt.Errorf("%s 重复引用 %s", listName, id)
|
||
}
|
||
seen[id] = true
|
||
target, ok := targets[id]
|
||
if !ok {
|
||
return nil, fmt.Errorf("%s 引用的 %s 在关系表中不存在(rId=%s 断链)——请用 housestyle kit 重建", listName, what, id)
|
||
}
|
||
out = append(out, target)
|
||
}
|
||
if len(targets) != len(ids) {
|
||
return nil, fmt.Errorf("%s 与关系表数量不一致(清单 %d 条,关系表 %d 条)——存在未引用或缺失的 %s 关系,请用 housestyle kit 重建", listName, len(ids), len(targets), what)
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// ooxmlNamespaceStrict 扫描包内全部 XML 部件:元素或属性使用了未声明的
|
||
// 命名空间前缀即报错。判定基于声明集合而非 URI 形态(Go 解码器对未声明
|
||
// 前缀保留原始前缀名作为 Space;已声明则解析为完整 URI,urn: 等 scheme
|
||
// 同样正确解析),避免对非 http URI 的误伤。
|
||
func ooxmlNamespaceStrict(zr *zip.Reader) error {
|
||
for _, f := range zr.File {
|
||
if !strings.HasSuffix(f.Name, ".xml") && !strings.HasSuffix(f.Name, ".rels") {
|
||
continue
|
||
}
|
||
declared, err := ooxmlDeclaredNamespaces(f)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
rc, err := f.Open()
|
||
if err != nil {
|
||
return fmt.Errorf("部件 %s 无法读取(%v)", f.Name, err)
|
||
}
|
||
dec := xml.NewDecoder(rc)
|
||
var scanErr error
|
||
scan:
|
||
for {
|
||
tok, err := dec.Token()
|
||
if err == io.EOF {
|
||
break
|
||
}
|
||
if err != nil {
|
||
scanErr = fmt.Errorf("部件 %s 不是合法 XML(%v)", f.Name, err)
|
||
break
|
||
}
|
||
se, ok := tok.(xml.StartElement)
|
||
if !ok {
|
||
continue
|
||
}
|
||
if sp := ooxmlUnboundPrefix(se.Name.Space, declared); sp != "" {
|
||
scanErr = fmt.Errorf("部件 %s 使用了未声明的命名空间前缀 xmlns:%s(元素 <%s:%s>)——根元素补 xmlns:%s 声明或用 housestyle kit 重建", f.Name, sp, sp, se.Name.Local, sp)
|
||
break
|
||
}
|
||
for _, a := range se.Attr {
|
||
if sp := ooxmlUnboundPrefix(a.Name.Space, declared); sp != "" {
|
||
scanErr = fmt.Errorf("部件 %s 使用了未声明的命名空间前缀 xmlns:%s(属性 %s:%s)——根元素补 xmlns:%s 声明或用 housestyle kit 重建", f.Name, sp, sp, a.Name.Local, sp)
|
||
break scan
|
||
}
|
||
}
|
||
}
|
||
rc.Close()
|
||
if scanErr != nil {
|
||
return scanErr
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ooxmlDeclaredNamespaces 收集部件内全部 xmlns 声明(含默认命名空间与任意
|
||
// 位置的限定声明)。前缀 xmlns 为保留名:Go 把 xmlns:r 声明报为
|
||
// Space="xmlns"、Local="r";默认声明 xmlns="…" 报为 Space=""、Local="xmlns"。
|
||
func ooxmlDeclaredNamespaces(f *zip.File) (map[string]bool, error) {
|
||
rc, err := f.Open()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("部件 %s 无法读取(%v)", f.Name, err)
|
||
}
|
||
defer rc.Close()
|
||
out := map[string]bool{"xml": true, "xmlns": true}
|
||
dec := xml.NewDecoder(rc)
|
||
for {
|
||
tok, err := dec.Token()
|
||
if err == io.EOF {
|
||
return out, nil
|
||
}
|
||
if err != nil {
|
||
return nil, fmt.Errorf("部件 %s 不是合法 XML(%v)", f.Name, err)
|
||
}
|
||
se, ok := tok.(xml.StartElement)
|
||
if !ok {
|
||
continue
|
||
}
|
||
for _, a := range se.Attr {
|
||
switch {
|
||
case a.Name.Space == "xmlns":
|
||
out[a.Value] = true
|
||
case a.Name.Space == "" && a.Name.Local == "xmlns":
|
||
out[a.Value] = true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ooxmlUnboundPrefix 返回未绑定的前缀名;已声明、无前缀与内建 xml 前缀返回空串。
|
||
func ooxmlUnboundPrefix(space string, declared map[string]bool) string {
|
||
if space == "" || declared[space] {
|
||
return ""
|
||
}
|
||
return space
|
||
}
|
||
|
||
// ooxmlCTOverrides 解析 [Content_Types].xml 的 Override 清单(部件路径 → 内容类型)。
|
||
func ooxmlCTOverrides(zr *zip.Reader) (map[string]string, error) {
|
||
f, err := ooxmlPart(zr, "[Content_Types].xml")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
rc, err := f.Open()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("[Content_Types].xml 无法读取(%v)", err)
|
||
}
|
||
defer rc.Close()
|
||
out := map[string]string{}
|
||
dec := xml.NewDecoder(rc)
|
||
for {
|
||
tok, err := dec.Token()
|
||
if err == io.EOF {
|
||
return out, nil
|
||
}
|
||
if err != nil {
|
||
return nil, fmt.Errorf("[Content_Types].xml 不是合法 XML(%v)", err)
|
||
}
|
||
se, ok := tok.(xml.StartElement)
|
||
if !ok || se.Name.Local != "Override" {
|
||
continue
|
||
}
|
||
partName, contentType := "", ""
|
||
for _, a := range se.Attr {
|
||
switch a.Name.Local {
|
||
case "PartName":
|
||
partName = a.Value
|
||
case "ContentType":
|
||
contentType = a.Value
|
||
}
|
||
}
|
||
out[partName] = contentType
|
||
}
|
||
}
|
||
|
||
// ooxmlWellFormed 确认部件存在且可被 XML 解析器完整读取。
|
||
func ooxmlWellFormed(zr *zip.Reader, part string) error {
|
||
f, err := ooxmlPart(zr, part)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
rc, err := f.Open()
|
||
if err != nil {
|
||
return fmt.Errorf("部件 %s 无法读取(%v)", part, err)
|
||
}
|
||
defer rc.Close()
|
||
dec := xml.NewDecoder(rc)
|
||
for {
|
||
_, err := dec.Token()
|
||
if err == io.EOF {
|
||
return nil
|
||
}
|
||
if err != nil {
|
||
return fmt.Errorf("部件 %s 不是合法 XML(%v)", part, err)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ooxmlRequireElement 确认部件内出现指定局部名的元素(不校验位置与属性)。
|
||
func ooxmlRequireElement(zr *zip.Reader, part, local string) error {
|
||
f, err := ooxmlPart(zr, part)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
rc, err := f.Open()
|
||
if err != nil {
|
||
return fmt.Errorf("部件 %s 无法读取(%v)", part, err)
|
||
}
|
||
defer rc.Close()
|
||
dec := xml.NewDecoder(rc)
|
||
for {
|
||
tok, err := dec.Token()
|
||
if err == io.EOF {
|
||
break
|
||
}
|
||
if err != nil {
|
||
return fmt.Errorf("部件 %s 不是合法 XML(%v)", part, err)
|
||
}
|
||
if se, ok := tok.(xml.StartElement); ok && se.Name.Local == local {
|
||
return nil
|
||
}
|
||
}
|
||
return fmt.Errorf("部件 %s 缺少 <%s> 声明", part, local)
|
||
}
|
||
|
||
// ooxmlAttrNS 取指定命名空间下的属性值。
|
||
func ooxmlAttrNS(se xml.StartElement, space, local string) (string, bool) {
|
||
for _, a := range se.Attr {
|
||
if a.Name.Space == space && a.Name.Local == local {
|
||
return a.Value, true
|
||
}
|
||
}
|
||
return "", false
|
||
}
|
||
|
||
// ooxmlPart 按部件路径精确查找(ZIP 内无目录条目,直接比对名字)。
|
||
func ooxmlPart(zr *zip.Reader, name string) (*zip.File, error) {
|
||
for _, f := range zr.File {
|
||
if f.Name == name {
|
||
return f, nil
|
||
}
|
||
}
|
||
return nil, fmt.Errorf("缺少必备部件 %s", name)
|
||
}
|
||
|
||
// ooxmlRel 是 rels 部件里的一条关系:Id、类型 URI 与目标(原样保留相对形式)。
|
||
type ooxmlRel struct {
|
||
id string
|
||
typ string
|
||
target string
|
||
}
|
||
|
||
// ooxmlRels 解析 part 的关系表(<dir>/_rels/<base>.rels)。
|
||
func ooxmlRels(zr *zip.Reader, part string) ([]ooxmlRel, error) {
|
||
dir, base := path.Split(part)
|
||
relsPart := path.Join(dir, "_rels", base+".rels")
|
||
f, err := ooxmlPart(zr, relsPart)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("%s 的关系表缺失(%v)", part, err)
|
||
}
|
||
rc, err := f.Open()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("%s 无法读取(%v)", relsPart, err)
|
||
}
|
||
defer rc.Close()
|
||
var rels []ooxmlRel
|
||
dec := xml.NewDecoder(rc)
|
||
for {
|
||
tok, err := dec.Token()
|
||
if err == io.EOF {
|
||
break
|
||
}
|
||
if err != nil {
|
||
return nil, fmt.Errorf("%s 不是合法 XML(%v)", relsPart, err)
|
||
}
|
||
se, ok := tok.(xml.StartElement)
|
||
if !ok || se.Name.Local != "Relationship" {
|
||
continue
|
||
}
|
||
var rel ooxmlRel
|
||
for _, attr := range se.Attr {
|
||
switch attr.Name.Local {
|
||
case "Id":
|
||
rel.id = attr.Value
|
||
case "Type":
|
||
rel.typ = attr.Value
|
||
case "Target":
|
||
rel.target = attr.Value
|
||
}
|
||
}
|
||
rels = append(rels, rel)
|
||
}
|
||
return rels, nil
|
||
}
|
||
|
||
// ooxmlResolve 把关系目标解析为包内部件路径:绝对目标去掉前导 /,
|
||
// 相对目标基于源部件目录解析(path.Join 自带 .. 清理)。
|
||
func ooxmlResolve(srcPart, target string) string {
|
||
if strings.HasPrefix(target, "/") {
|
||
return strings.TrimPrefix(target, "/")
|
||
}
|
||
return path.Join(path.Dir(srcPart), target)
|
||
}
|