Files
Agent-System-Backend/internal/tools/ooxmlgate.go
weichen a2f4978be8 Detect Word packages from OPC metadata
Why: The registration gate classified Word packages from a hard-coded ZIP member path, allowing alternate main-part paths to bypass the gate and rejecting unrelated ZIP archives.

Evidence: Focused OPC registration tests; go test -count=1 ./internal/tools; go test -count=1 ./...; go test -race -count=1 ./internal/tools ./internal/runmgr; go vet ./...; go build ./...; git diff --check.

Impact: Resolve package-level officeDocument targets safely, classify all conventional Word main content types through OPC defaults or overrides, and fail closed on malformed, ambiguous, external, or traversing metadata while preserving non-Word artifacts.

Refs: Task 10; /tmp/task10-backend-final-closure-last.md
2026-10-15 22:46:21 +00:00

911 lines
28 KiB
Go
Raw Permalink 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.
// 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"
"net/url"
"path"
"path/filepath"
"strings"
)
const (
nsPackageRel = "http://schemas.openxmlformats.org/package/2006/relationships"
nsContentTypes = "http://schemas.openxmlformats.org/package/2006/content-types"
nsPresentationML = "http://schemas.openxmlformats.org/presentationml/2006/main"
nsOfficeDocRel = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
relOfficeDoc = nsOfficeDocRel + "/officeDocument"
relStrictOffice = "http://purl.oclc.org/ooxml/officeDocument/relationships/officeDocument"
ctWordDocument = "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"
ctWordTemplate = "application/vnd.openxmlformats-officedocument.wordprocessingml.template.main+xml"
ctWordMacroDoc = "application/vnd.ms-word.document.macroEnabled.main+xml"
ctWordMacroTpl = "application/vnd.ms-word.template.macroEnabledTemplate.main+xml"
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
}
}
// isWordprocessingMLPackage identifies Word OPC packages from the package-level
// officeDocument relationship and the resolved main part's content type. A
// purported officeDocument package with malformed, ambiguous, or unsafe
// metadata is treated as Word so the registration boundary fails closed.
func isWordprocessingMLPackage(content []byte) bool {
zr, err := zip.NewReader(bytes.NewReader(content), int64(len(content)))
if err != nil {
return false
}
isWord, purportsOfficeDocument, err := ooxmlClassifyWordPackage(zr)
if err != nil {
return purportsOfficeDocument
}
return isWord
}
func ooxmlClassifyWordPackage(zr *zip.Reader) (isWord, purportsOfficeDocument bool, err error) {
relsFile, found, err := ooxmlUniquePart(zr, "_rels/.rels")
if err != nil {
return false, true, err
}
if !found {
return false, false, nil
}
rels, purportsOfficeDocument, err := ooxmlPackageRelationships(relsFile)
if err != nil {
return false, purportsOfficeDocument, err
}
var officeRels []ooxmlRel
for _, rel := range rels {
if rel.typ == relOfficeDoc || rel.typ == relStrictOffice {
officeRels = append(officeRels, rel)
}
}
if len(officeRels) == 0 {
return false, false, nil
}
if len(officeRels) != 1 {
return false, true, fmt.Errorf("package relationships contain %d officeDocument relationships", len(officeRels))
}
mainPart, err := ooxmlSafePackageTarget(officeRels[0].target, officeRels[0].targetMode)
if err != nil {
return false, true, err
}
if _, found, err := ooxmlUniquePartFold(zr, mainPart); err != nil || !found {
if err != nil {
return false, true, err
}
return false, true, fmt.Errorf("officeDocument target %q is missing", mainPart)
}
contentTypesFile, found, err := ooxmlUniquePart(zr, "[Content_Types].xml")
if err != nil {
return false, true, err
}
if !found {
return false, true, fmt.Errorf("officeDocument package is missing [Content_Types].xml")
}
contentTypes, err := ooxmlParseContentTypes(contentTypesFile)
if err != nil {
return false, true, err
}
contentType, found := contentTypes.contentType(mainPart)
if !found {
return false, true, fmt.Errorf("officeDocument target %q has no content type", mainPart)
}
return ooxmlIsWordMainContentType(contentType), true, nil
}
type ooxmlContentTypeTable struct {
defaults map[string]string
overrides map[string]string
}
func (t ooxmlContentTypeTable) contentType(part string) (string, bool) {
if contentType, ok := t.overrides[strings.ToLower(part)]; ok {
return contentType, true
}
extension := strings.TrimPrefix(path.Ext(part), ".")
contentType, ok := t.defaults[strings.ToLower(extension)]
return contentType, ok
}
func ooxmlIsWordMainContentType(contentType string) bool {
switch contentType {
case ctWordDocument, ctWordTemplate, ctWordMacroDoc, ctWordMacroTpl:
return true
default:
return false
}
}
func ooxmlPackageRelationships(file *zip.File) ([]ooxmlRel, bool, error) {
rc, err := file.Open()
if err != nil {
return nil, false, fmt.Errorf("_rels/.rels cannot be read: %w", err)
}
defer rc.Close()
dec := xml.NewDecoder(rc)
var rels []ooxmlRel
seenIDs := map[string]bool{}
rootSeen := false
rootValid := false
depth := 0
purportsOfficeDocument := false
var metadataErr error
for {
tok, err := dec.Token()
if err == io.EOF {
break
}
if err != nil {
return nil, purportsOfficeDocument, fmt.Errorf("_rels/.rels is not valid XML: %w", err)
}
switch node := tok.(type) {
case xml.EndElement:
depth--
continue
case xml.StartElement:
depth++
if !rootSeen {
rootSeen = true
rootValid = node.Name.Space == nsPackageRel && node.Name.Local == "Relationships"
if !rootValid {
metadataErr = fmt.Errorf("_rels/.rels has an invalid Relationships root")
}
continue
}
var rel ooxmlRel
seenAttrs := map[string]bool{}
for _, attr := range node.Attr {
if attr.Name.Space != "" {
continue
}
if seenAttrs[attr.Name.Local] {
metadataErr = fmt.Errorf("_rels/.rels contains duplicate %s attributes", attr.Name.Local)
}
seenAttrs[attr.Name.Local] = true
switch attr.Name.Local {
case "Id":
rel.id = attr.Value
case "Type":
rel.typ = attr.Value
case "Target":
rel.target = attr.Value
case "TargetMode":
rel.targetMode = attr.Value
}
}
if rel.typ == relOfficeDoc || rel.typ == relStrictOffice {
purportsOfficeDocument = true
}
if depth != 2 || node.Name.Space != nsPackageRel || node.Name.Local != "Relationship" {
metadataErr = fmt.Errorf("_rels/.rels contains an invalid relationship element")
continue
}
if rel.id == "" || rel.typ == "" || rel.target == "" {
metadataErr = fmt.Errorf("_rels/.rels contains an incomplete Relationship")
continue
}
if seenIDs[rel.id] {
metadataErr = fmt.Errorf("_rels/.rels contains duplicate relationship Id %q", rel.id)
continue
}
seenIDs[rel.id] = true
rels = append(rels, rel)
}
}
if !rootSeen {
return nil, false, fmt.Errorf("_rels/.rels is empty")
}
if !rootValid || metadataErr != nil {
return nil, purportsOfficeDocument, metadataErr
}
return rels, purportsOfficeDocument, nil
}
func ooxmlParseContentTypes(file *zip.File) (ooxmlContentTypeTable, error) {
table := ooxmlContentTypeTable{
defaults: map[string]string{},
overrides: map[string]string{},
}
rc, err := file.Open()
if err != nil {
return table, fmt.Errorf("[Content_Types].xml cannot be read: %w", err)
}
defer rc.Close()
dec := xml.NewDecoder(rc)
rootSeen := false
for {
tok, err := dec.Token()
if err == io.EOF {
break
}
if err != nil {
return table, fmt.Errorf("[Content_Types].xml is not valid XML: %w", err)
}
se, ok := tok.(xml.StartElement)
if !ok {
continue
}
if !rootSeen {
rootSeen = true
if se.Name.Space != nsContentTypes || se.Name.Local != "Types" {
return table, fmt.Errorf("[Content_Types].xml has an invalid Types root")
}
continue
}
if se.Name.Space != nsContentTypes {
continue
}
attrs := map[string]string{}
for _, attr := range se.Attr {
if attr.Name.Space == "" {
attrs[attr.Name.Local] = attr.Value
}
}
switch se.Name.Local {
case "Default":
extension := strings.ToLower(attrs["Extension"])
contentType := attrs["ContentType"]
if extension == "" || strings.ContainsAny(extension, ".\\/ \t\r\n") || contentType == "" {
return table, fmt.Errorf("[Content_Types].xml contains an invalid Default")
}
if _, exists := table.defaults[extension]; exists {
return table, fmt.Errorf("[Content_Types].xml contains duplicate Default for %q", extension)
}
table.defaults[extension] = contentType
case "Override":
partName, err := ooxmlSafePartName(attrs["PartName"])
if err != nil || attrs["ContentType"] == "" {
return table, fmt.Errorf("[Content_Types].xml contains an invalid Override")
}
key := strings.ToLower(partName)
if _, exists := table.overrides[key]; exists {
return table, fmt.Errorf("[Content_Types].xml contains duplicate Override for %q", partName)
}
table.overrides[key] = attrs["ContentType"]
}
}
if !rootSeen {
return table, fmt.Errorf("[Content_Types].xml is empty")
}
return table, nil
}
func ooxmlSafePackageTarget(target, targetMode string) (string, error) {
if targetMode != "" && !strings.EqualFold(targetMode, "Internal") {
return "", fmt.Errorf("officeDocument target must be internal")
}
return ooxmlSafePartPath(target, strings.HasPrefix(target, "/"))
}
func ooxmlSafePartName(partName string) (string, error) {
if !strings.HasPrefix(partName, "/") {
return "", fmt.Errorf("OPC part name must be absolute")
}
return ooxmlSafePartPath(partName, true)
}
func ooxmlSafePartPath(raw string, absolute bool) (string, error) {
if raw == "" || strings.ContainsAny(raw, "\\\x00") {
return "", fmt.Errorf("unsafe OPC part path %q", raw)
}
parsed, err := url.Parse(raw)
if err != nil || parsed.IsAbs() || parsed.Host != "" || parsed.Opaque != "" || parsed.RawQuery != "" || parsed.Fragment != "" {
return "", fmt.Errorf("unsafe OPC part path %q", raw)
}
decoded, err := url.PathUnescape(parsed.EscapedPath())
if err != nil || strings.ContainsAny(decoded, "\\\x00") {
return "", fmt.Errorf("unsafe OPC part path %q", raw)
}
if absolute != strings.HasPrefix(decoded, "/") {
return "", fmt.Errorf("unsafe OPC part path %q", raw)
}
decoded = strings.TrimPrefix(decoded, "/")
if decoded == "" || strings.HasSuffix(decoded, "/") {
return "", fmt.Errorf("unsafe OPC part path %q", raw)
}
for _, segment := range strings.Split(decoded, "/") {
if segment == "" || segment == "." || segment == ".." {
return "", fmt.Errorf("unsafe OPC part path %q", raw)
}
}
return decoded, nil
}
func ooxmlUniquePart(zr *zip.Reader, name string) (*zip.File, bool, error) {
var found *zip.File
for _, file := range zr.File {
if file.Name != name {
continue
}
if found != nil {
return nil, false, fmt.Errorf("duplicate OPC metadata part %s", name)
}
found = file
}
return found, found != nil, nil
}
func ooxmlUniquePartFold(zr *zip.Reader, name string) (*zip.File, bool, error) {
var found *zip.File
for _, file := range zr.File {
if !strings.EqualFold(file.Name, name) {
continue
}
if found != nil {
return nil, false, fmt.Errorf("duplicate OPC part %s", name)
}
found = file
}
return found, found != nil, 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()
// xml 前缀由 XML 规范预绑定。Go 解码器把它展开成 URI,而不是留下前缀名。
out := map[string]bool{"xml": true, "xmlns": true, "http://www.w3.org/XML/1998/namespace": 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
targetMode 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)
}