diff --git a/internal/prompts/library/base-deliverables.md b/internal/prompts/library/base-deliverables.md
index 2235dcc..2d3ddd8 100644
--- a/internal/prompts/library/base-deliverables.md
+++ b/internal/prompts/library/base-deliverables.md
@@ -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 充数——那样生成的包缺母版与主题,结构自检会拒绝登记。
要求与边界:
diff --git a/internal/prompts/library/tool-run-python.md b/internal/prompts/library/tool-run-python.md
index 4e24b92..e45b563 100644
--- a/internal/prompts/library/tool-run-python.md
+++ b/internal/prompts/library/tool-run-python.md
@@ -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`。
diff --git a/internal/runmgr/exec_tool_artifact_test.go b/internal/runmgr/exec_tool_artifact_test.go
index e6256f9..10c011e 100644
--- a/internal/runmgr/exec_tool_artifact_test.go
+++ b/internal/runmgr/exec_tool_artifact_test.go
@@ -77,9 +77,11 @@ func TestRunPythonRegisterArtifactBackfill(t *testing.T) {
if err := tools.RegisterInterpreterTools(c.mgr.registry); err != nil {
t.Fatal(err)
}
+ // 夹具必须是结构合法的最小 docx:登记口会做 OPC 结构自检,
+ // 伪 zip(PK 魔数+垃圾字节)现在会被拒绝而非登记。
py := c.ExecuteTool(core.ToolCallRequest{
CallID: "py1", Name: "run_python",
- Arguments: json.RawMessage(`{"script":"open('note.docx','wb').write(b'PK\\x03\\x04hello')\n"}`),
+ Arguments: json.RawMessage(`{"script":"import zipfile\nwith zipfile.ZipFile('note.docx','w') as z:\n z.writestr('[Content_Types].xml','')\n z.writestr('word/document.xml','')\n"}`),
})
if py.Status != "ok" || !strings.Contains(py.Output, "exit=0") {
t.Fatalf("run_python: %s %s", py.Status, py.Output)
diff --git a/internal/skills/recipes/house-style/housestyle/lint.py b/internal/skills/recipes/house-style/housestyle/lint.py
index 0163705..916e53c 100644
--- a/internal/skills/recipes/house-style/housestyle/lint.py
+++ b/internal/skills/recipes/house-style/housestyle/lint.py
@@ -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
diff --git a/internal/skills/recipes/house-style/tests/test_housestyle.py b/internal/skills/recipes/house-style/tests/test_housestyle.py
index 49fd0b3..0afba31 100644
--- a/internal/skills/recipes/house-style/tests/test_housestyle.py
+++ b/internal/skills/recipes/house-style/tests/test_housestyle.py
@@ -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 = (''
+ ''
+ ''
+ ''
+ '')
+ p = "http://schemas.openxmlformats.org/presentationml/2006/main"
+ parts = {
+ "[Content_Types].xml": ct,
+ "_rels/.rels": (''
+ ''),
+ "ppt/presentation.xml": f'',
+ "ppt/_rels/presentation.xml.rels": (''
+ ''),
+ "ppt/slides/slide1.xml": f'',
+ "ppt/slideLayouts/slideLayout1.xml": f'',
+ }
+ 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"])
diff --git a/internal/tools/exec.go b/internal/tools/exec.go
index df972e2..79b945e 100644
--- a/internal/tools/exec.go
+++ b/internal/tools/exec.go
@@ -134,6 +134,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)
if err := checkToolExternalEffect(rc); err != nil {
diff --git a/internal/tools/ooxmlgate.go b/internal/tools/ooxmlgate.go
new file mode 100644
index 0000000..d7f5f1c
--- /dev/null
+++ b/internal/tools/ooxmlgate.go
@@ -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 缺少 (幻灯片母版列表)——母版与主题是必备结构")
+ }
+ 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 的关系表(/_rels/.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)
+}
diff --git a/internal/tools/ooxmlgate_test.go b/internal/tools/ooxmlgate_test.go
new file mode 100644
index 0000000..9ee2957
--- /dev/null
+++ b/internal/tools/ooxmlgate_test.go
@@ -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 = ``
+ gatePres = ``
+ gateSlide = ``
+ gateLayout = ``
+ gateMaster = ``
+ gateTheme = ``
+)
+
+func relXML(rels ...string) string {
+ return `` +
+ strings.Join(rels, "") + ``
+}
+
+const (
+ relSlide = ``
+ relMaster = ``
+ relLayout = ``
+ relToMaster = ``
+ relTheme = ``
+)
+
+// 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": ``,
+ "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, "", "", 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"] = ""
+ }, "不是合法 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": ``,
+ })
+ 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": ``,
+ })
+ 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)
+ }
+}