Reject structurally broken OOXML at artifact registration
Why: An agent bypassing the housestyle kit hand-assembled OOXML with zipfile; the resulting pptx lacked masters, theme and docProps. Text extraction succeeded and every existing check passed, so a package that Office refuses to open reached the user as a deliverable. Evidence: Focused Go suite covers a valid minimal pptx, the incident- shaped package, nine chain-break variants and register-level integration; the housestyle python suite adds BrokenPptxTests for masterless, kit-made and unopenable files. Full combined suite receipt follows on the task card before review. Impact: register_artifact now runs a pure-Go OPC structural check for docx/xlsx/pptx and refuses broken packages with a kit rebuild hint; lint_pptx gains structure-broken assertions (master/theme/layout chain, open-failure fallback) that template mode cannot downgrade; prompts forbid hand-assembled OOXML and specify the missing-library fallback. Refs: Dev task feature/pptx-register-structural-gate
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: base.deliverables
|
||||
layer: base
|
||||
version: 7
|
||||
version: 8
|
||||
title: 交付物工作流
|
||||
description: 生成文档、表格、演示稿的工作流、样式决策与技能入口(常驻基座)
|
||||
---
|
||||
@@ -38,6 +38,7 @@ description: 生成文档、表格、演示稿的工作流、样式决策与技
|
||||
- **docx-author / xlsx-author / pptx-author**:各类交付物的版面范式和最小示例。
|
||||
- 小队角色用 `load_skill`;其他路径用 `run_shell` 查看技能文件,例如 `cat "$AGENT_SKILLS_DIR/house-style/SKILL.md"`。不要假设工作区里有 `skills/` 目录。
|
||||
- 缺库时:`pip install --user python-docx openpyxl python-pptx`。PDF 仿制可选 `pdfplumber`;没装就说明只能近似,并请用户确认主色和字体。
|
||||
- 缺库又装不上时,如实告知用户当前环境无法生成带排版的 Office 交付物并说明缺什么;禁止改用 zipfile/字符串手工拼 OOXML 充数——那样生成的包缺母版与主题,结构自检会拒绝登记。
|
||||
|
||||
要求与边界:
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
---
|
||||
id: tool.run_python
|
||||
layer: tool
|
||||
version: 1
|
||||
version: 2
|
||||
title: run_python 工具描述
|
||||
description: Python 执行原语的模型侧说明文案
|
||||
---
|
||||
执行完整 Python 脚本(python3,工作目录=会话工作区)。适用:数据处理、生成 Excel/PPT/图片(housestyle kit)、批量操作。Word 交付物优先写 .md 源后用 run_shell 跑 `python3 -m housestyle.build`,不要在脚本里手工拼 docx。报错先读退出码与 traceback 定位行号再改,同一错误不要原样重跑。生成带排式的文件(xlsx/pptx)先读 `$AGENT_SKILLS_DIR/house-style/SKILL.md` 的构件表,不确定签名先 `inspect.signature`。
|
||||
执行完整 Python 脚本(python3,工作目录=会话工作区)。适用:数据处理、生成 Excel/PPT/图片(housestyle kit)、批量操作。Word 交付物优先写 .md 源后用 run_shell 跑 `python3 -m housestyle.build`。任何 Office 交付物(docx/xlsx/pptx)都禁止用 zipfile 或字符串手工拼 OOXML——手拼的包缺母版与主题,register_artifact 的结构自检会直接拒绝;一律走 housestyle kit 构件。缺库先 `pip install --user python-pptx python-docx openpyxl`,装不上就如实告知用户当前环境无法生成本类交付物,不要退回手拼。报错先读退出码与 traceback 定位行号再改,同一错误不要原样重跑。生成带排版的文件(xlsx/pptx)先读 `$AGENT_SKILLS_DIR/house-style/SKILL.md` 的构件表,不确定签名先 `inspect.signature`。
|
||||
|
||||
@@ -102,7 +102,11 @@ def lint_docx(path: str) -> list[dict]:
|
||||
|
||||
|
||||
def lint_pptx(path: str) -> list[dict]:
|
||||
prs = Presentation(path)
|
||||
try:
|
||||
prs = Presentation(path)
|
||||
except Exception as exc:
|
||||
return [_issue(path, "error", "structure-broken",
|
||||
f"无法按 PPTX 解析({type(exc).__name__}: {exc})——疑似手拼 OOXML 或文件损坏,必须用 housestyle kit 重建")]
|
||||
issues = []
|
||||
templated = "housestyle-template" in (prs.core_properties.subject or "")
|
||||
|
||||
@@ -111,23 +115,50 @@ def lint_pptx(path: str) -> list[dict]:
|
||||
severity = "warning"
|
||||
issues.append(_issue(path, severity, rule, message, where))
|
||||
|
||||
if prs.slide_width and prs.slide_height:
|
||||
ratio = prs.slide_width / prs.slide_height
|
||||
if abs(ratio - 16 / 9) > 0.05:
|
||||
add("slide-size", "error", f"不是 16:9(宽高比 {ratio:.3f})")
|
||||
for index, slide in enumerate(prs.slides, start=1):
|
||||
texts = []
|
||||
for shape in slide.shapes:
|
||||
if not shape.has_text_frame:
|
||||
continue
|
||||
body = shape.text_frame.text.strip()
|
||||
if body:
|
||||
texts.append(body)
|
||||
count = sum(1 for p in shape.text_frame.paragraphs if p.text.strip())
|
||||
if count > 6:
|
||||
add("bullet-overflow", "error", f"一页超过 6 条要点({count})", f"slide {index}")
|
||||
if not texts:
|
||||
add("empty-title", "error", "空白页", f"slide {index}")
|
||||
# 结构底线(OPC 合法性,不随模板降级):母版、主题部件必须存在,每张
|
||||
# slide 的版式→母版链可达。python-pptx 对缺失母版完全容忍,但 Office
|
||||
# 会按残缺包拒绝打开;docx/xlsx 没有这类缺口(打开成功即要求核心部件存在)。
|
||||
try:
|
||||
masters = list(prs.slide_masters)
|
||||
except Exception as exc:
|
||||
add("structure-broken", "error", f"母版列表不可读({type(exc).__name__}: {exc})")
|
||||
masters = []
|
||||
if not masters:
|
||||
add("structure-broken", "error", "没有幻灯片母版——缺母版/主题的包 Office 会判为损坏")
|
||||
for master in masters:
|
||||
try:
|
||||
reltypes = [rel.reltype for rel in master.part.rels.values()]
|
||||
except Exception as exc:
|
||||
add("structure-broken", "error", f"母版关系不可读({type(exc).__name__}: {exc})")
|
||||
continue
|
||||
if not any(t.endswith("/theme") for t in reltypes):
|
||||
add("structure-broken", "error", "母版没有关联主题(theme)")
|
||||
|
||||
try:
|
||||
slides = list(prs.slides)
|
||||
except Exception as exc:
|
||||
add("structure-broken", "error", f"幻灯片列表不可读({type(exc).__name__}: {exc})")
|
||||
return issues
|
||||
for index, slide in enumerate(slides, start=1):
|
||||
try:
|
||||
slide.slide_layout.slide_master
|
||||
except Exception as exc:
|
||||
add("structure-broken", "error", f"版式/母版链断裂({type(exc).__name__}: {exc})", f"slide {index}")
|
||||
try:
|
||||
texts = []
|
||||
for shape in slide.shapes:
|
||||
if not shape.has_text_frame:
|
||||
continue
|
||||
body = shape.text_frame.text.strip()
|
||||
if body:
|
||||
texts.append(body)
|
||||
count = sum(1 for p in shape.text_frame.paragraphs if p.text.strip())
|
||||
if count > 6:
|
||||
add("bullet-overflow", "error", f"一页超过 6 条要点({count})", f"slide {index}")
|
||||
if not texts:
|
||||
add("empty-title", "error", "空白页", f"slide {index}")
|
||||
except Exception as exc:
|
||||
add("structure-broken", "error", f"幻灯片正文不可读({type(exc).__name__}: {exc})", f"slide {index}")
|
||||
return issues
|
||||
|
||||
|
||||
|
||||
@@ -212,3 +212,53 @@ class BuildTests(unittest.TestCase):
|
||||
# PYTHONPATH 由调用方保证;这里直接在 house-style 根目录下跑模块。
|
||||
if r.returncode not in (0, 1):
|
||||
self.fail(r.stderr[:200])
|
||||
|
||||
|
||||
class BrokenPptxTests(unittest.TestCase):
|
||||
"""手拼 OOXML 的结构底线:缺母版/主题/版式链必须报 structure-broken。"""
|
||||
|
||||
@staticmethod
|
||||
def _broken_pptx(path):
|
||||
import zipfile
|
||||
ct = ('<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
|
||||
'<Default Extension="xml" ContentType="application/xml"/>'
|
||||
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
|
||||
'<Override PartName="/ppt/presentation.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/>'
|
||||
'<Override PartName="/ppt/slides/slide1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/></Types>')
|
||||
p = "http://schemas.openxmlformats.org/presentationml/2006/main"
|
||||
parts = {
|
||||
"[Content_Types].xml": ct,
|
||||
"_rels/.rels": ('<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||
'<Relationship Id="r1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="ppt/presentation.xml"/></Relationships>'),
|
||||
"ppt/presentation.xml": f'<?xml version="1.0"?><p:presentation xmlns:p="{p}"><p:sldIdLst><p:sldId id="2" r:id="r1" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"/></p:sldIdLst></p:presentation>',
|
||||
"ppt/_rels/presentation.xml.rels": ('<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||
'<Relationship Id="r1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide1.xml"/></Relationships>'),
|
||||
"ppt/slides/slide1.xml": f'<?xml version="1.0"?><p:sld xmlns:p="{p}"><p:cSld><p:spTree/></p:cSld></p:sld>',
|
||||
"ppt/slideLayouts/slideLayout1.xml": f'<?xml version="1.0"?><p:sldLayout xmlns:p="{p}"/>',
|
||||
}
|
||||
with zipfile.ZipFile(path, "w") as z:
|
||||
for name, body in parts.items():
|
||||
z.writestr(name, body)
|
||||
|
||||
def test_masterless_package_reports_structure_broken(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = str(Path(tmp) / "broken.pptx")
|
||||
self._broken_pptx(path)
|
||||
rules = {item["rule"] for item in _errors(lint_pptx(path))}
|
||||
self.assertIn("structure-broken", rules)
|
||||
|
||||
def test_kit_output_has_no_structure_errors(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = str(Path(tmp) / "ok.pptx")
|
||||
prs = pptx_kit.new_presentation(theme="modern", title="自检")
|
||||
pptx_kit.add_title_slide(prs, "标题", "副标题")
|
||||
pptx_kit.save(prs, path)
|
||||
rules = {item["rule"] for item in _errors(lint_pptx(path))}
|
||||
self.assertNotIn("structure-broken", rules)
|
||||
|
||||
def test_unopenable_file_reports_structure_broken(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = str(Path(tmp) / "fake.pptx")
|
||||
Path(path).write_text("这不是 zip", encoding="utf-8")
|
||||
issues = lint_pptx(path)
|
||||
self.assertEqual([item["rule"] for item in issues], ["structure-broken"])
|
||||
|
||||
@@ -131,6 +131,9 @@ func execRegisterArtifact(rc ToolExecCtx, args json.RawMessage) (string, error)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := ooxmlStructuralCheck(filepath.Base(abs), b); err != nil {
|
||||
return "", fmt.Errorf("register_artifact: %s 结构自检未通过:%v。请用 housestyle kit 重建(见 $AGENT_SKILLS_DIR/house-style/SKILL.md),不要用 zipfile 手工拼 OOXML", a.Path, err)
|
||||
}
|
||||
name := filepath.Base(abs)
|
||||
mime := mimeOfExt(name)
|
||||
art, err := rc.WriteArtifact(name, artifactKindOf(name, mime), mime, b)
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
// OOXML 交付物结构自检(登记口 L1):纯 Go 静态校验,零外部依赖。
|
||||
// 背景:Agent 绕开 housestyle kit 用 zipfile 手拼 OOXML 会产出缺母版/主题/文档
|
||||
// 属性的残缺包——正文抽取照常成功,直到 Office 按 OPC 完整性校验打开时才报
|
||||
// 损坏。这里按必备部件与 slide→layout→master→theme 引用链在登记前拦截,
|
||||
// 失败信息直接指向 kit 重建路径。结构级评分(housestyle.lint / 终轮门禁)
|
||||
// 不在本层:本层只保证「Office 打得开」这条底线。
|
||||
package tools
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 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 nil
|
||||
}
|
||||
|
||||
// ooxmlCheckPptx 校验演示稿的完整引用链:presentation 声明母版列表,
|
||||
// 每张 slide 可达 slideLayout,版式可达 slideMaster,母版可达 theme。
|
||||
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>(幻灯片母版列表)——母版与主题是必备结构")
|
||||
}
|
||||
presRels, err := ooxmlRels(zr, "ppt/presentation.xml")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var slides, masters []string
|
||||
for _, rel := range presRels {
|
||||
switch {
|
||||
case strings.HasSuffix(rel.typ, "/slide"):
|
||||
slides = append(slides, ooxmlResolve("ppt/presentation.xml", rel.target))
|
||||
case strings.HasSuffix(rel.typ, "/slideMaster"):
|
||||
masters = append(masters, ooxmlResolve("ppt/presentation.xml", rel.target))
|
||||
}
|
||||
}
|
||||
if len(masters) == 0 {
|
||||
return fmt.Errorf("presentation 未引用任何幻灯片母版(slideMaster)——母版与主题是必备结构")
|
||||
}
|
||||
for _, master := range masters {
|
||||
if err := ooxmlMasterChain(zr, master); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(slides) == 0 {
|
||||
return fmt.Errorf("presentation 未引用任何幻灯片")
|
||||
}
|
||||
seenLayouts := map[string]bool{}
|
||||
for _, slide := range slides {
|
||||
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 seenLayouts[layout] {
|
||||
continue
|
||||
}
|
||||
seenLayouts[layout] = true
|
||||
if err := ooxmlWellFormed(zr, layout); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ooxmlLayoutMaster(zr, layout); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ooxmlMasterChain 校验母版存在且其关系表引用了存在的 theme。
|
||||
func ooxmlMasterChain(zr *zip.Reader, master string) error {
|
||||
if err := ooxmlWellFormed(zr, master); err != nil {
|
||||
return err
|
||||
}
|
||||
rels, err := ooxmlRels(zr, master)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, rel := range rels {
|
||||
if strings.HasSuffix(rel.typ, "/theme") {
|
||||
return ooxmlWellFormed(zr, ooxmlResolve(master, rel.target))
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("母版 %s 没有指向 theme 的关系", master)
|
||||
}
|
||||
|
||||
// ooxmlLayoutMaster 校验版式的关系表引用了存在的 slideMaster。
|
||||
func ooxmlLayoutMaster(zr *zip.Reader, layout string) error {
|
||||
rels, err := ooxmlRels(zr, layout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, rel := range rels {
|
||||
if strings.HasSuffix(rel.typ, "/slideMaster") {
|
||||
return ooxmlWellFormed(zr, ooxmlResolve(layout, rel.target))
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("版式 %s 没有指向 slideMaster 的关系", layout)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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 部件里的一条关系:类型 URI 与目标(原样保留相对形式)。
|
||||
type ooxmlRel struct {
|
||||
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 "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)
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// zipOf 按序打包字符串部件(保持插入序便于断言)。
|
||||
func zipOf(t *testing.T, files map[string]string) []byte {
|
||||
t.Helper()
|
||||
buf := new(bytes.Buffer)
|
||||
w := zip.NewWriter(buf)
|
||||
for name, body := range files {
|
||||
f, err := w.Create(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := f.Write([]byte(body)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
const (
|
||||
gateCTXML = `<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="xml" ContentType="application/xml"/></Types>`
|
||||
gatePres = `<?xml version="1.0"?><p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"><p:sldMasterIdLst><p:sldMasterId id="1"/></p:sldMasterIdLst><p:sldIdLst><p:sldId id="2"/></p:sldIdLst></p:presentation>`
|
||||
gateSlide = `<?xml version="1.0"?><p:sld xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"/>`
|
||||
gateLayout = `<?xml version="1.0"?><p:sldLayout xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"/>`
|
||||
gateMaster = `<?xml version="1.0"?><p:sldMaster xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"/>`
|
||||
gateTheme = `<?xml version="1.0"?><a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"/>`
|
||||
)
|
||||
|
||||
func relXML(rels ...string) string {
|
||||
return `<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">` +
|
||||
strings.Join(rels, "") + `</Relationships>`
|
||||
}
|
||||
|
||||
const (
|
||||
relSlide = `<Relationship Id="r1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide1.xml"/>`
|
||||
relMaster = `<Relationship Id="r2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="slideMasters/slideMaster1.xml"/>`
|
||||
relLayout = `<Relationship Id="r1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/>`
|
||||
relToMaster = `<Relationship Id="r1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="../slideMasters/slideMaster1.xml"/>`
|
||||
relTheme = `<Relationship Id="r1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" Target="../theme/theme1.xml"/>`
|
||||
)
|
||||
|
||||
// gateValidPptx 是结构合法的最小演示稿:引用链 slide→layout→master→theme 齐备。
|
||||
func gateValidPptx(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
return zipOf(t, map[string]string{
|
||||
"[Content_Types].xml": gateCTXML,
|
||||
"_rels/.rels": relXML(),
|
||||
"ppt/presentation.xml": gatePres,
|
||||
"ppt/_rels/presentation.xml.rels": relXML(relSlide, relMaster),
|
||||
"ppt/slides/slide1.xml": gateSlide,
|
||||
"ppt/slides/_rels/slide1.xml.rels": relXML(relLayout),
|
||||
"ppt/slideLayouts/slideLayout1.xml": gateLayout,
|
||||
"ppt/slideLayouts/_rels/slideLayout1.xml.rels": relXML(relToMaster),
|
||||
"ppt/slideMasters/slideMaster1.xml": gateMaster,
|
||||
"ppt/slideMasters/_rels/slideMaster1.xml.rels": relXML(relTheme),
|
||||
"ppt/theme/theme1.xml": gateTheme,
|
||||
})
|
||||
}
|
||||
|
||||
// gateBrokenPptx 复刻事故包:只有 presentation + slides + 幽灵 layout,
|
||||
// 无母版/主题/docProps,presentation 缺 sldMasterIdLst,slide 无关系表。
|
||||
func gateBrokenPptx(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
return zipOf(t, map[string]string{
|
||||
"[Content_Types].xml": gateCTXML,
|
||||
"_rels/.rels": relXML(),
|
||||
"ppt/presentation.xml": `<?xml version="1.0"?><p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"><p:sldIdLst><p:sldId id="2"/></p:sldIdLst></p:presentation>`,
|
||||
"ppt/_rels/presentation.xml.rels": relXML(relSlide),
|
||||
"ppt/slides/slide1.xml": gateSlide,
|
||||
"ppt/slideLayouts/slideLayout1.xml": gateLayout,
|
||||
})
|
||||
}
|
||||
|
||||
func TestOoxmlGateValidPptx(t *testing.T) {
|
||||
if err := ooxmlStructuralCheck("交付物.pptx", gateValidPptx(t)); err != nil {
|
||||
t.Fatalf("valid pptx must pass: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOoxmlGateBrokenPptxIncident(t *testing.T) {
|
||||
err := ooxmlStructuralCheck("机械臂腹部超声设计方案.pptx", gateBrokenPptx(t))
|
||||
if err == nil {
|
||||
t.Fatal("incident-shaped pptx must be rejected")
|
||||
}
|
||||
for _, want := range []string{"母版", "sldMasterIdLst"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("error %q must mention %q", err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOoxmlGatePptxChainBreaks(t *testing.T) {
|
||||
base := func(t *testing.T) map[string]string {
|
||||
t.Helper()
|
||||
return map[string]string{
|
||||
"[Content_Types].xml": gateCTXML,
|
||||
"_rels/.rels": relXML(),
|
||||
"ppt/presentation.xml": gatePres,
|
||||
"ppt/_rels/presentation.xml.rels": relXML(relSlide, relMaster),
|
||||
"ppt/slides/slide1.xml": gateSlide,
|
||||
"ppt/slides/_rels/slide1.xml.rels": relXML(relLayout),
|
||||
"ppt/slideLayouts/slideLayout1.xml": gateLayout,
|
||||
"ppt/slideLayouts/_rels/slideLayout1.xml.rels": relXML(relToMaster),
|
||||
"ppt/slideMasters/slideMaster1.xml": gateMaster,
|
||||
"ppt/slideMasters/_rels/slideMaster1.xml.rels": relXML(relTheme),
|
||||
"ppt/theme/theme1.xml": gateTheme,
|
||||
}
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
mut func(m map[string]string)
|
||||
want string
|
||||
}{
|
||||
{"slide-no-rels", func(m map[string]string) {
|
||||
delete(m, "ppt/slides/_rels/slide1.xml.rels")
|
||||
}, "关系表缺失"},
|
||||
{"slide-no-layout-rel", func(m map[string]string) {
|
||||
m["ppt/slides/_rels/slide1.xml.rels"] = relXML()
|
||||
}, "没有指向 slideLayout"},
|
||||
{"layout-missing", func(m map[string]string) {
|
||||
delete(m, "ppt/slideLayouts/slideLayout1.xml")
|
||||
}, "缺少必备部件"},
|
||||
{"layout-no-master-rel", func(m map[string]string) {
|
||||
m["ppt/slideLayouts/_rels/slideLayout1.xml.rels"] = relXML()
|
||||
}, "没有指向 slideMaster"},
|
||||
{"master-no-theme-rel", func(m map[string]string) {
|
||||
m["ppt/slideMasters/_rels/slideMaster1.xml.rels"] = relXML()
|
||||
}, "没有指向 theme"},
|
||||
{"theme-missing", func(m map[string]string) {
|
||||
delete(m, "ppt/theme/theme1.xml")
|
||||
}, "缺少必备部件"},
|
||||
{"pres-no-master-idlst", func(m map[string]string) {
|
||||
m["ppt/presentation.xml"] = strings.Replace(gatePres, "<p:sldMasterIdLst><p:sldMasterId id=\"1\"/></p:sldMasterIdLst>", "", 1)
|
||||
}, "sldMasterIdLst"},
|
||||
{"pres-no-slide-rels", func(m map[string]string) {
|
||||
m["ppt/_rels/presentation.xml.rels"] = relXML(relMaster)
|
||||
}, "未引用任何幻灯片"},
|
||||
{"xml-malformed", func(m map[string]string) {
|
||||
m["ppt/slides/slide1.xml"] = "<p:sld>"
|
||||
}, "不是合法 XML"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
m := base(t)
|
||||
tc.mut(m)
|
||||
err := ooxmlStructuralCheck("deck.pptx", zipOf(t, m))
|
||||
if err == nil {
|
||||
t.Fatalf("%s: must be rejected", tc.name)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("%s: error %q must mention %q", tc.name, err, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOoxmlGateDocxXlsxAndPassthrough(t *testing.T) {
|
||||
validDocx := zipOf(t, map[string]string{
|
||||
"[Content_Types].xml": gateCTXML,
|
||||
"word/document.xml": `<?xml version="1.0"?><w:document xmlns:w="urn:x"/>`,
|
||||
})
|
||||
if err := ooxmlStructuralCheck("方案.docx", validDocx); err != nil {
|
||||
t.Fatalf("valid docx must pass: %v", err)
|
||||
}
|
||||
if err := ooxmlStructuralCheck("方案.docx", zipOf(t, map[string]string{"[Content_Types].xml": gateCTXML})); err == nil {
|
||||
t.Fatal("docx without document.xml must be rejected")
|
||||
}
|
||||
validXlsx := zipOf(t, map[string]string{
|
||||
"[Content_Types].xml": gateCTXML,
|
||||
"xl/workbook.xml": `<?xml version="1.0"?><workbook xmlns="urn:x"/>`,
|
||||
})
|
||||
if err := ooxmlStructuralCheck("数据.xlsx", validXlsx); err != nil {
|
||||
t.Fatalf("valid xlsx must pass: %v", err)
|
||||
}
|
||||
if err := ooxmlStructuralCheck("备注.md", []byte("# 标题\n正文")); err != nil {
|
||||
t.Fatalf("non-office files must pass through: %v", err)
|
||||
}
|
||||
if err := ooxmlStructuralCheck("坏.pptx", []byte("这是一段文本,不是 zip")); err == nil {
|
||||
t.Fatal("text masquerading as pptx must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// 登记口集成:残缺包在 register_artifact 处被拒并给出重建指引;合法包照常登记。
|
||||
func TestRegisterArtifactStructuralGate(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
if err := RegisterExecTools(r); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root := t.TempDir()
|
||||
rc := &execFake{root: root, owner: "o1", conv: "c1", execOn: true}
|
||||
sandbox := filepath.Join(root, "o1", "c1")
|
||||
if err := os.MkdirAll(sandbox, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
broken := filepath.Join(sandbox, "坏.pptx")
|
||||
if err := os.WriteFile(broken, gateBrokenPptx(t), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res := interpCall(t, r, rc, "register_artifact", `{"path":"坏.pptx"}`)
|
||||
if res.Status != "error" || !strings.Contains(res.Output, "结构自检未通过") || !strings.Contains(res.Output, "housestyle") {
|
||||
t.Fatalf("broken pptx must be refused with rebuild hint: %s %s", res.Status, res.Output)
|
||||
}
|
||||
good := filepath.Join(sandbox, "好.pptx")
|
||||
if err := os.WriteFile(good, gateValidPptx(t), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res = interpCall(t, r, rc, "register_artifact", `{"path":"好.pptx"}`)
|
||||
if res.Status != "ok" || !strings.Contains(res.Output, "已登记交付物") {
|
||||
t.Fatalf("valid pptx must register: %s %s", res.Status, res.Output)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user