dsh-ppt-studio 1.1.1 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  在 DeepSeek Harness 里说需求,它把 PPT 做出来:先定大纲和版式,再逐页制作,每页过一遍自动检查,最后交付一个能用 PowerPoint 打开、继续改的 `.pptx`。
4
4
 
5
- 当前版本 **1.1.1**,安装包约 **890 kB**(1.0.3 及更早的版本是 35 MB,因为随包带着 4 套没人用过的导入模板,1.0.4 把它们删了)。
5
+ 当前版本 **1.1.2**,安装包约 **890 kB**(1.0.3 及更早的版本是 35 MB,因为随包带着 4 套没人用过的导入模板,1.0.4 把它们删了)。
6
6
 
7
7
  装上之后,**这个 profile 的所有会话都能用**,不需要专门切到某个预设。预设「PPT 工作室」提供的是人格和身份,不是"开关"。
8
8
 
@@ -325,6 +325,9 @@ v1.0.0 修订前旧引擎的结构 bug(graphicFrame 里嵌了 `<a:xfrm>`,Pow
325
325
  - line 只有两个点,多点折线要拆成首尾相接的多条。
326
326
  - chart 支持 bar/line/pie;图表里的分类名、数值、单位、图例需要你自己用文本元素补上(DSL 里没有这些字段)。
327
327
  - 背景支持 hex、主题引用、纯色、图片(cover/contain/fill)。
328
+ - 图片 `src` 是相对 deck 根的路径:`media/子目录/图.png`、中文或带空格的文件名都可以(预览会正确处理)。
329
+ 两个**不同目录下的同名文件**也没问题——导出会自动给包内部件名加哈希避免互相覆盖(PPT 里的图片内容不受影响,导出输出里会说明改了什么)。
330
+ `src` 写 http(s) 网址时**只有预览能显示**,导出会写 1×1 白色占位并醒目警告(插件不联网取图)。
328
331
 
329
332
  **这些不做,都有替代路径**:
330
333
 
@@ -439,7 +442,7 @@ ppt_visual # Office 真渲染复核(有 Office 时;audit
439
442
 
440
443
  ```bash
441
444
  node scripts/build.mjs # 免 tsc:src → lib 复制(纯 ESM JS,源码即产物)
442
- npm test # build + LF 守卫 + smoke(270 断言)+ 预设漂移自检
445
+ npm test # build + LF 守卫 + smoke(276 断言)+ 预设漂移自检
443
446
  npm run check:eol # 发行字节守卫:跟踪的文本文件必须全 LF(--fix 就地修)
444
447
  npm run fresh # 用户视角终验:干净克隆 npm test + 真装一遍
445
448
  npm run test:bundle # 安装路径自证:隔离 DSH_HOME + 真 dsh plugin add + dump-config
@@ -100,6 +100,7 @@
100
100
  ③ **包围盒精确等于线段跨度**:不能 `max(1,…)` 兜底,否则水平线 `cy=1pt` 会被画成"12pt×1pt 的对角线"=假斜线(`cy=0`/`cx=0` 合法,Office 自己也这么写);只有零长线给 1×1 兜底。
101
101
  **自证**:`connectorEndsFromXml()` 从写出的 XML 反解端点并与源 `points` 逐条比对(<0.01pt),并断言 arrow=true 时 `tailEnd` 在 `a:ln` 内;
102
102
  结果进 `report.parity.lines{Exp,Out,Wrong}`,`parity.ok` 同时要求表/图/线三项归零。这是"预览层对、成品层错"这类跨层不一致的机器防线。
103
+ **2026-09-26 补媒体 parity**:`mediaExp`(不同 `srcPath` 数)== `mediaOut`(包内 `ppt/media/` 部件数)并纳入 `ok`。起因是审计实测:不同目录下的**同名**媒体(`media/a/logo.png` + `media/b/logo.png`)按 basename 命名时会塌成同一个部件(后写覆盖先写)⇒ **成品里一张图被静默换成另一张**,而图数量 parity 依旧 N/N、`warnings=[]`、`mediaPlaceholders=[]` 全绿 —— 只有"媒体身份"这条判据抓得到。
103
104
  - 页面背景:`p:bg` 原生(solidFill / blipFill + 媒体嵌入 + rels,v0.3 修复)。
104
105
  - `out`:**绝对路径原样使用 / 相对路径相对 deck 目录**(E1)。
105
106
  - **auto-fit 与下限**(E2 修复;2026-09-06 下限语义修订):只缩当 `overflowY > 1`(与 verify 同阈值)→ verify 通过 ⇒ 不缩字;**下限 = 用户指令**:`theme.minFontSize` 显式 → `min(原字号, 用户下限)`(绝不升字);未设置 → 无强制(60% 原字号防荒谬保底,最低 6pt);到下限仍溢出 → `floorHit: true` 进 report(工具输出 ✗ 警告,绝不静默)。
@@ -172,6 +173,7 @@
172
173
  - 关键参数语义:
173
174
  - `ppt_verify autoDeclare=true`:写声明 → 重新 load + render + verify,报告"已写入声明 + 剩余错误"。
174
175
  - `ppt_verify pages="2,5-7"`(v0.14.6):局部校验——只在内存过滤 layout.pages(**不改写 layout.json**,防部分校验静默漏检其余页);`autoDeclare` 路径不支持 pages(声明必须整册)。
176
+ **2026-09-26 修"局部假绿"**:门禁行必须带审阅范围(`门禁(局部):✓ 选中的 N 页通过 —— 其余 M 页本次未检查`)。旧实现先过滤 `layout.pages` 再计数,于是"只审 1 页且其干净"会打印与整册通过**完全同形**的 `门禁:✓ 通过`——恰恰是这一段自述要防的"静默漏检",只是从文件搬到了报告里。同轮 `verifyDeck` **增量**返回 `errors/warns/suggestions`(每条带 `page/pageName`),`tools.js`/`templates.js` 不再反解文本(后者旧正则 `\[✗\] (\w+)` 会把 `out-of-page` 截成 `out`)。
175
177
  - `ppt_export engine auto = pptd`(写死);`out` 支持绝对路径。
176
178
  - `ppt_schema`(无参,输出 SCHEMA_REF 速查)/ `ppt_new(dir, name)`(scaffoldProject,已存在 deck.yaml 拒绝覆盖)。
177
179
  - `shotOne`:每张截图独立临时 `--user-data-dir` + `--disk-cache-size=1`(修复过 Edge 缓存导致截图雷同)。
@@ -221,6 +223,7 @@
221
223
  - **msrender.js(v0.8.0)**:`findPowerPoint()`(POWERPNT.EXE 候选路径探测,无则 null → 通道自动隐藏);`renderPptxToPng(pptx, outDir, {width,height,timeoutMs})`——写 BOM ps1(Windows PowerShell 按 GBK 读无 BOM 文件会乱码中文路径)→ spawn powershell.exe -File → COM `Presentations.Open(只读)` + 逐页 `Slides.Item(i).Export(...,'PNG',1920,1080)` → 解析 `OK:<n>`;安全:只读打开、finally Quit、超时 SIGKILL 并清理残留 POWERPNT。
222
224
  - **用途三点**:① 理解用户原稿(参考任务先看后做,工作流 6a)② 成品视觉审核(导出后审核真实观感,audit 档自动联动)③ 模板真相层整页(2.11)。
223
225
  - **preview-server.js(v0.6.x)**:`buildPreview(dir)` → 预览根 `~/.dsh/ppt-studio/preview/<token>/`(pages/ + media/ 复制 + .meta.json 源映射);`registerPreviewRoute` 在 webServer 注册**无尾斜杠** prefix `/ppt-preview`(prefix 语义是前缀匹配,带尾斜杠永不命中);`resolveToken` 持久回退。
226
+ **2026-09-26 修两处(都是"预览坏、成品正常")**:① media 改用 `cp(recursive)` **整目录镜像** —— 旧实现只 `readdir` 顶层再逐个 `copyFile`,遇到 `media/子目录` 会把目录当文件拷,Windows 上直接 `EPERM: copyfile …`,`ppt_preview` **整体失败**(不是"缺图")且在预览根留下半成品;② 抽出并导出 `previewFileFor(root, rawRel)` —— 先 `decodeURIComponent`(浏览器把 `中文 图.png` 请求成 `%E4%B8%AD%E6%96%87%20…`,而 `new URL().pathname` 保留编码 ⇒ 旧实现 `existsSync` 必失败、图片明明已拷进预览根却 404),`..` 改为**按路径段**判定(不再误杀 `a..b.png` 这类合法文件名)。
224
227
  - **`ctx.effect` 真语义与随之修正的两处(2026-09-14,真库探针实测)**:`ctx.effect(cb)` **立即执行 cb**,用 **cb 的返回值**作 disposer。
225
228
  实测(`new Context()` + 探针):调用后立刻观察到 `cb-body-ran`,手动 dispose 后才 `returned-fn-ran`。
226
229
  ① 路由 refcount 原先把清理体写在 cb 体内 ⇒ 注册当刻 `count--` 并 `cancelled=true`,**文档承诺的"首个注册者真注册、最后卸载者真卸载"从未成立**(注册后立即被取消);已改为 cleanup 包在返回函数里。
@@ -414,7 +417,7 @@
414
417
 
415
418
  | 层 | 手段 | 触发点 | 覆盖 |
416
419
  |---|---|---|---|
417
- | L0 单测 | `scripts/smoke.mjs`(270 断言) | 每次改动后 | schema/渲染/校验/导出/导入/媒体/背景/声明制(含闭包)/美学/Quick/引擎/样式回环/噪声过滤/**连线方向** |
420
+ | L0 单测 | `scripts/smoke.mjs`(276 断言) | 每次改动后 | schema/渲染/校验/导出/导入/媒体/背景/声明制(含闭包)/美学/Quick/引擎/样式回环/噪声过滤/**连线方向** |
418
421
  | L1 真实回归 | `scripts/regression-real.mjs` | 每次改动后(有资产则跑) | 19 页产能 deck(0 错误 + 0 缩字)、WPS UTF-16 文件导入 |
419
422
  | L2 数字门禁 | render→verify 逐页 | 每页制作后(工作流内) | overlap/出界/溢出/对齐 |
420
423
  | L3 视觉审阅 | shot + read_image / **Office 真渲染 + read_image**(v0.8,有条件) | 每页/整体(工作流内) | 构图/断行/对比度/孤字(估算漏报兜底)/真实观感 |
@@ -479,5 +482,6 @@ docs/ 本档案库(00 设计源 / 01 需求目标 / 02 技术报告
479
482
  - validatePage:**元素级 TEXT_STYLE_KEYS 报错**(样式必须在 content 内)+ YAML 解析错误附笔误提示。
480
483
  - import:theme colors = 原稿**全量色板**(c1-7 高频 + c8 起扩展);媒体引用一致性自检(缺失 → 提示 + 1×1 占位)。
481
484
  - export:缺失媒体占位兜底(mediaPlaceholders 进报告);msrender `renderPptxToPng({pages})` 按页 COM 渲染(png 文件名 = 原页号)。
485
+ - **媒体部件命名(2026-09-26)**:整册共享、按 `srcPath` 唯一 —— ASCII 且未被占用 ⇒ **原名不动**(既有工程产物逐字节不变);非 ASCII/空格或**重名** ⇒ `stem-<hash8>.<ext>`(hash 取相对路径,跨机器确定;`ppt/media/` 是包内部件名,用户看不到)。同一个名同时用于 rels Target 与包内条目;改名进 `report.mediaRenamed` 并在导出输出里**明说**(不静默)。
482
486
  README.md 使用者入口(轻量,指向 docs/)
483
487
  ```
@@ -5,6 +5,42 @@
5
5
 
6
6
  ---
7
7
 
8
+ ## [1.1.2] 2026-09-26 —— 媒体链路与成品对齐(同名静默错图 / 中文名预览 404 / 子目录预览崩)+ 局部审阅假绿 + `--page` 假承诺
9
+
10
+ ### 触发(用户原话)
11
+ "既然你倾向B,那就按B来吧。"(承接上一轮"先审后改":用户要求先确认问题**真实存在**、再用不违背原目标的改法;本轮为方案 B 的第一批)
12
+
13
+ ### 审计与证伪(全部只读;先审后改)
14
+ | 原怀疑 | 结论 |
15
+ |---|---|
16
+ | 门禁靠 `[✗]` 文本计数(元素 id 注入) | **原理由不成立**:`verify.js:472` 固定格式生成、message 无合法注入点(YAML 键不能含换行)⇒ 不拿它当理由;真问题是下面的"局部假绿" |
17
+ | 预览 media 不递归 | 成立,且**症状更重**:不是缺图,是 `EPERM: copyfile '<deck>\media\sub'` 让 `ppt_preview` **整体失败** |
18
+ | `/ppt fidelity --page` 空操作 | 成立:回执称"已记录",而 `<dir>/state.json` 的 `spec` 仍为 `{}`;页级字段在 DSL/state 里根本不存在 |
19
+ | `DSH_HOME` 口径分裂 | 成立(state/preview 是模块常量、templates/index 是调用时求值)⇒ 按方案 B **本轮不做**,挪到 1.1.3 |
20
+ | **新发现**:不同目录同名媒体 → 成品静默用错图 | 成立:2 张不同的图 ⇒ 包内仅 1 个 `ppt/media/logo.png`,而 `parity.ok=true`、`warnings=[]`、`mediaPlaceholders=[]` 全绿 |
21
+ | **新发现**:非 ASCII/空格文件名 → 预览 404 | 成立:图已拷进预览根,但请求路径 `%E4%B8%AD%E6%96%87%20…` 未解码 ⇒ `existsSync=false`(导出正常) |
22
+ | **新发现**:templates.js 错误码被 `\w+` 截断 | 成立(`out-of-page` → `out`) |
23
+
24
+ ### 改动
25
+ - `src/pptd/export-pptx.js`:媒体部件名改为**整册共享、按 srcPath 唯一** —— ASCII 且未被占用 ⇒ **原名不动**(既有产物逐字节不变);非 ASCII/空格或**重名** ⇒ `stem-<hash8>.<ext>`(hash 取相对路径)。同一个名同时用于 rels Target 与包内条目;改名进 `report.mediaRenamed` 并在导出输出里**明说**。
26
+ - `src/pptd/export-pptx.js`:parity 增 `mediaExp/mediaOut`(不同 srcPath 数 == 包内媒体部件数)并计入 `ok` —— 这条才抓得到"两张图塌成一个部件"(图数量 parity 是 2/2,抓不到)。
27
+ - `src/preview-server.js`:media 改 `cp(recursive)` **整目录镜像**(修 EPERM 崩溃);抽出并导出 `previewFileFor()`:先 `decodeURIComponent`(修中文/空格 404),`..` 改为**先判后 normalize、按路径段**判定(不误杀 `a..b.png`,越级一律拒绝)。
28
+ - `src/verify.js`:`verifyDeck` **增量**返回 `errors/warns/suggestions`(每条带 `page/pageName`);`text/total/size` 与文本格式零变化。
29
+ - `src/tools.js`:门禁计数改用结构化 `v.errors.length`;`pages=` 局部审阅时门禁行改带范围;导出输出新增"媒体部件改名"提示与新 parity 字段。
30
+ - `src/templates.js`:收纳报告改用结构化错误码(修 `\w+` 截断)。
31
+ - `src/commands.js`:`/ppt fidelity --page` 改为**明确拒绝**(照 engine/pptxgenjs 先例);会话级回执如实标注"记录/展示用,当前不改门禁与流程"。
32
+ - 未改:**提示词零改动**(`router.js` workflowSection 不动 ⇒ 工作流基线无需重建)、任何门禁阈值/严格性、DSL schema 的既有字段语义。
33
+
34
+ ### 验证
35
+ - `npm test` = **276 通过 / 0 失败**(新增 §54×6);预设自检漂移 0。
36
+ - §54 覆盖:同名媒体各占一个部件(4 部件、A 内容×3、B 内容×1、rels 与包内一一对应)/ASCII 名保持原名且改名仅 2 例/媒体 parity=4/4/预览子目录递归镜像 + 中文名可服务/`..` 与畸形编码被拦而 `a..b.png` 放行/真 `ppt_verify` handler 的局部门禁措辞(`门禁(局部):✓ 选中的 1 页通过 —— 其余 1 页**本次未检查**`)。
37
+ - **真消费者复核(P1 纪律:改 OOXML 部件名/rels 必须过真 PowerPoint)**:本机 `POWERPNT.EXE` COM 渲染成功(1920×1080 PNG),**读图确认左/中/右三张图内容各不相同**(左=小图 A、中=学术模板截图 B、右=中文名图 A)——修复前中间那张会与左边相同。渲染图落 `%TEMP%/ppt-office-*/rendered/01.png`(临时物,复核后即删)。
38
+
39
+ ### 影响
40
+ - 修复前:不同目录同名媒体会在成品里**静默显示错图**(所有自证全绿);含 `media/子目录/` 的工程 `ppt_preview` 直接失败;中文/空格文件名预览 404 而导出正常。
41
+ - 兼容性:**普通工程(ASCII 唯一文件名)产物逐字节不变**;改名只发生在冲突/非 ASCII 时且**明说**;`verifyDeck` 的返回是纯增量(旧消费方零影响)。
42
+ - 未做(方案 B 第二批,1.1.3):`DSH_HOME` 口径统一 + smoke 全局隔离(含"`npm test` 会把 smoke 模板与预览缓存写进开发机真实 `~/.dsh/ppt-studio/`")。
43
+
8
44
  ## [1.1.1] 2026-09-26 —— 修复桌面端激活失败(`webServer.register` 真契约 + 晚激活改走 `inject`);npm 定为默认安装通道
9
45
 
10
46
  ### 触发(用户原话)
@@ -47,6 +83,18 @@ ws.register(...).then is not a function at registerPreviewRoute
47
83
  - 安装口径统一后用户只记一句话:**web** `dsh plugin --profile web add dsh-ppt-studio`;**桌面端**「插件管理」里填 `dsh-ppt-studio`。
48
84
  - 版本:`package.json` → **1.1.1**(patch:修复 + 文档)。发布走上一轮建好的 OIDC 全自动通道(`release: published` → 下载 Release 资产 → `npm publish`),零手工、零长期 token。
49
85
 
86
+ ### 发布结果(1.1.1,2026-09-26)—— 上一条建的 OIDC 通道首次真跑即成功
87
+ | 环节 | 实测 |
88
+ |---|---|
89
+ | 构件 | `dsh-ppt-studio-1.1.1-20260926-0551-412bff17.tgz`(sha256 `412bff17…`);`npm run sync` 三向一致 + 用户视角终验 9/0 + 安装路径自证 28/0 |
90
+ | CI 自动发布 | run [`36193791853`](https://github.com/zbsph/dsh-ppt-studio/actions/runs/36193791853) **success**:release 事件触发 → 等资产 → 取最新那条 → OIDC `npm publish` → 发后自证 `registry integrity == 本地 sha512` |
91
+ | registry | `dsh-ppt-studio@1.1.1`、`latest = 1.1.1`、`fileCount 179`;`dist.integrity = sha512-uwWnpKvP…`、`shasum 885e8e58…` |
92
+ | **发布身份** | `_npmUser = {"name":"GitHub Actions","email":"npm-oidc-no-reply@github.com","trustedPublisher":{"id":"github","oidcConfigId":"oidc:987851d7-…"}}` ⇒ **确证走的是 Trusted Publishing(OIDC),不是任何 token** |
93
+ | provenance | `dist.attestations.provenance.predicateType = https://slsa.dev/provenance/v1`(公开仓库 + 公开包 + OIDC ⇒ 自动生成,无需 `--provenance`) |
94
+ | 同字节 | 从 registry 下载的 tarball sha256 `412bff17…` == 本地发版构件(915,527 B) |
95
+ | 顺带确证 | 上一轮的悬案"信任关系是否放行 `npm publish`"**已确证放行**——否则这一步会以 `E_STAGE_REQUIRED` 失败(预检时只是"强烈提示") |
96
+ | 本机 | `npm run sync` 按文档流程把本版装进本机 **web** profile(依赖规格 = v1.1.1 资产 URL);**桌面端** profile 仍为 1.1.0(CLI 拒绝 `--profile desktop`,需在桌面端插件管理里重新安装/升级) |
97
+
50
98
  ## [发布通道 · npm 全自动] 2026-09-26 —— npm 已发布 1.1.0(逐字节核对);发布改走 Trusted Publishing(OIDC),发版零手工
51
99
 
52
100
  ### 触发(用户原话)
@@ -104,7 +104,7 @@
104
104
  想要会话级隔离就切后者(`dsh plugin --profile web remove <包>` → `node scripts/install.mjs` → 重启;README §10 有切换与回退命令),
105
105
  日常迭代照旧 `--local`——它现在**两种挂载模式都自证**(非 bundle 走"穿过 junction 读到的字节")。
106
106
  插件**无法**在 bundle 挂载下自己按预设过滤:注册发生在插件 `apply`(profile 级),要按预设隔离就得从预设组合里注册。
107
- - 版本号:未获用户指示不改(当前 **1.1.1**;历史值见 docs/03);**资产名带构建时间 + 构建戳**(2026-09-15 起):`dsh-ppt-studio-<版本>-<YYYYMMDD-HHmm>-<sha256 前 8 位>.tgz`。
107
+ - 版本号:未获用户指示不改(当前 **1.1.2**;历史值见 docs/03);**资产名带构建时间 + 构建戳**(2026-09-15 起):`dsh-ppt-studio-<版本>-<YYYYMMDD-HHmm>-<sha256 前 8 位>.tgz`。
108
108
  > 2026-09-18 改名说明:包名由 `@dsh-external/dsh-ppt-studio` 改为**无 scope** 的 `dsh-ppt-studio`(发布到 npm),
109
109
  > 于是 `npm pack` 产出的资产名也从 `dsh-external-dsh-ppt-studio-…` 变成 `dsh-ppt-studio-…`;v1.0.2 及更早的资产仍在 Releases 页上(老用户重装不受影响)。
110
110
  **时间段的用处**:老资产**不删**(已装用户靠它重装),页面上会并排多条名字近似者,sha 段看不出新旧 ⇒ 取时间最新那条即当前版本;发布日志里同时打印本轮的完整资产名。
@@ -5,7 +5,7 @@
5
5
  > 结论一句话:**达到发布标准。** 五项原则均"良好"及以上;发现并修复 3 个严谨性问题;自包含测试体系(smoke 181 + preflight 11 + E2E 13 + real 2)可在无外部资产下复现全部验证。
6
6
 
7
7
  > **计数说明(2026-09-14 加)**:§一 是生成于 2026-09-05 的**历史快照**,其中 `smoke 181` 是当时的套件规模,**不改写**。
8
- > 套件规模会随每轮增量断言增长;**当前规模与最新结果只认 §二 2.1(持续维护)与 §六 发布标准核对**——现为 **smoke 270**。
8
+ > 套件规模会随每轮增量断言增长;**当前规模与最新结果只认 §二 2.1(持续维护)与 §六 发布标准核对**——现为 **smoke 276**。
9
9
 
10
10
  ---
11
11
 
@@ -70,10 +70,10 @@
70
70
 
71
71
  | 场景 | 类型 | 结果 |
72
72
  |---|---|---|
73
- | smoke.mjs(270 断言:全链路 + 路由幂等 + autodeclare 回归 + splice/slice zip 级 + 度量/声明/防呆 + **连线方向/箭头/包围盒** + **内置技能包与"老用户不受影响"超集断言** + **手册 vs 源码事实一致性 13 条** + **讲稿备注/实体解码/图表配色** + **答疑手册触发纪律 3 条** + **lib/ 入库一致性** + **M2 真产物页号契约与注入自证 4 条** + **python-pptx 兜底生成物过真消费者 2 条** + **表格字号三层同源与 audit 下限 2 条** + **质量档降级可见 3 条** + **引擎不静默切换 3 条** + **出界分级两码可分 1 条** + **surgicalMap 消费自证 2 条** + **工具描述页数守卫 1 条** + **表格门禁 2 条** + **换行鲁棒(CRLF+BOM)3 条** + **用户模板库两层目录与入库/物化/删除 5 条** + **DSH 0.1.7:预设声明 / 门禁新参照格式 / 安装器路径** + **桌面端安装器(桩工具链 dry-run,不碰真桌面端)**) | 自动化 | 270/270 |
73
+ | smoke.mjs(276 断言:全链路 + 路由幂等 + autodeclare 回归 + splice/slice zip 级 + 度量/声明/防呆 + **连线方向/箭头/包围盒** + **内置技能包与"老用户不受影响"超集断言** + **手册 vs 源码事实一致性 13 条** + **讲稿备注/实体解码/图表配色** + **答疑手册触发纪律 3 条** + **lib/ 入库一致性** + **M2 真产物页号契约与注入自证 4 条** + **python-pptx 兜底生成物过真消费者 2 条** + **表格字号三层同源与 audit 下限 2 条** + **质量档降级可见 3 条** + **引擎不静默切换 3 条** + **出界分级两码可分 1 条** + **surgicalMap 消费自证 2 条** + **工具描述页数守卫 1 条** + **表格门禁 2 条** + **换行鲁棒(CRLF+BOM)3 条** + **用户模板库两层目录与入库/物化/删除 5 条** + **DSH 0.1.7:预设声明 / 门禁新参照格式 / 安装器路径** + **桌面端安装器(桩工具链 dry-run,不碰真桌面端)**) | 自动化 | 276/276 |
74
74
  | **讲稿(备注)真消费者验证**(2026-09-14):① **真 PowerPoint COM** 打开带备注的导出件并逐页读回讲稿文本(多行 18 字 / 无备注页 NONE / 单行含 `<特殊> &` 15 字);② **python-pptx 1.0.2** 独立读回同样三页文本 + 重新打包成功(smoke 里以可选断言固化,缺 python 时跳过) | 真机/第三方 | ✓ 两路一致 |
75
75
  | **python-pptx 兜底引擎真消费者验证**(2026-09-18 新增,smoke §43):生成一段覆盖「shape 无填充 / 对象填充 `{color,alpha}` / 带描边 + line 连接线 + table + text」的 deck → `genPythonScript` → ① `python -m py_compile` 必须过;② `runPythonExport` 必须真跑出 .pptx,且 **python-pptx 自己打开它并读回**(1 页 ≥ 6 形状)。**为什么必须补**:这条路径此前在 `scripts/` 里**零调用**,而它是"pptd 硬失败时"的最后保险;python 是可选依赖,"跳过"与"通过"印同一个颜色,于是它连编译都过不去也没人知道 | 真机/第三方 | 2 条全绿 |
76
- | 文档计数自证(smoke 内):README/docs02/docs06/手册里所有"smoke N 断言 / N/N"必须等于真实断言数 | 自动化 | 270 全中 |
76
+ | 文档计数自证(smoke 内):README/docs02/docs06/手册里所有"smoke N 断言 / N/N"必须等于真实断言数 | 自动化 | 276 全中 |
77
77
  | **M2 通道真产物自证**(2026-09-18 新增,smoke §27.4~27.7):由 `ppt_render` + `ppt_measure` 真跑产出的 `layout.json`/`measured.json` 驱动——① 两档 1 基 `pageNo` 一一对应;② 在真产物上给**真实元素**注入溢出,必被捕获且 id 正确;③ 第 1 页的注入**不得串到第 2 页**(错位一页是原 bug 最危险的形态);④ 缺 `pageNo` 的旧版产物必须报 `measured-unpaired` 而不是静默返空。**为什么必须真产物**:原 bug 的全部成因就是"夹具照着函数期望的形状写、从没调用过真生产者"(夹具两侧同写 `index: 1` 掩盖了 0 基 vs 1 基的错配)| 自动化(真产物) | 4 条全绿 |
78
78
  | **安装路径真机自证**(`npm run test:bundle`,2026-09-14):隔离 `DSH_HOME` 起临时 profile → 真 `dsh plugin --profile web add <spec>` → 断言"无 no-dsh.bundle 警告 / 已自动进 `dsh.profile.bundles` / 已物化 / `--dump-config` 出现 ppt-studio 行";**两种 spec 都验过**:本地 tgz,**GitHub 资产 URL(用户实际命令)**。**2026-09-15 补升级路径**:同版本号、内容不同的第二个 tgz 再 add → 真换成新字节(marker 在位)+ 依赖规格改指新包 + bundles 不丢 + dump-config 仍在(**不碰用户 profiles**)。**2026-09-18 补 `--local` 迭代模式**:同一个隔离 profile 里跑 `release-sync --local` → 全程无上传 + 状态文件 `mode=local`/`mount=bundle-local`/`mountMatch`/`ok` + 规格变成指向 `_artifacts/` 的 `file:`(且恰好只留当前那一个)+ **挂载副本 `lib/index.js` 与仓库刚构建的逐字节相同**。**同日再补「预设行 + junction」模式**(用户要求"只有 PPT 工作室预设才有这些工具")→ **2026-09-18 事故后作废并重写为「预设健康」判据**:那条路在本 DSH 版本上**必然产出 broken 预设**(行的裸包名从 harness 解析,不是 profile)⇒ 前端选择器只渲染健康预设 ⇒ **用户根本选不到该预设**,而 profile 里又没 bundle ⇒ 两边都没工具(现场:245 个会话里 `agentPreset=ppt` 的 **0** 个,而所有文件级检查全绿)。**2026-09-24 再为 DSH 0.1.7 重写第 7 步**(预设改为向 `agentPresets` 注册表**声明**,目录发现被上游删除;`discoverPresets` 那个包已不存在):① 安装器 bundle 模式 exit 0 且**不再产出** `.agent-presets/`、并清理 0.1.6 遗留目录;② 校验"声明预设"所需的包内资产(`agent-presets/ppt/*` + `lib/preset-delivery.js`);③ `--isolate` **明确拒绝**(exit 1 + 两处源码依据);④ 它**不**把 pnpm 物化的包目录换成 junction;⑤ **预设健康的新判据**——组合里每一行的 `name` 都能从**注册表 mount 的 baseUrl**(= `dsh-web-app` 包目录)解析(实测 29 行全解析成功);⑥ 默认不镜像技能、显式 `--mirror-skills` 才镜像 | 真机/launcher | 28/28 |
79
79
  | **质量档降级可见**(2026-09-18 新增,smoke §45):把项目 `state.json` 写成非法 JSON → ① 必须**上报**而不是静默回落 `standard`;② 诊断必须 `degraded=true` 且带原因(供工具输出提示"audit 额外门禁未生效");③ **反例**:文件不存在必须 `error=null`(防把每个新工程都判成降级) | 自动化 | 3 条全绿 |
Binary file
Binary file
package/lib/commands.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * /ppt → 状态查询
4
4
  * /ppt on|off → 强制进入/退出工作流
5
5
  * /ppt free|mid|strict → 强制协作模式
6
- * /ppt fidelity strict|auto|free [--page N / --dir D]
6
+ * /ppt fidelity strict|auto|free [--dir D] (--page 未实现 → 明确拒绝,见分支内注释)
7
7
  * /ppt review none|points|every
8
8
  * /ppt engine pptd|python-pptx|pptxgenjs
9
9
  * /ppt quality quick|standard|audit
@@ -59,18 +59,19 @@ async function handle(invocation) {
59
59
  if (cmd === 'fidelity') {
60
60
  const v = (args.split(' ')[0] ?? '').toLowerCase()
61
61
  if (!['strict', 'auto', 'free'].includes(v)) return err('fidelity 取值 strict|auto|free')
62
- state.fidelity = v
63
- await save()
64
62
  const pageN = argValue(args, '--page')
65
- if (pageN && dir) {
66
- const proj = await loadProject(dir)
67
- proj.spec = proj.spec ?? {}
68
- const idx = Number(pageN) - 1
69
- for (const p of (proj.spec.pages ?? [])) { void p }
70
- await saveProject(dir, proj)
71
- return ok(`✓ 会话忠实度=${v}${pageN ? `(--page ${pageN} 已记录,页面级 fidelity 由模型写入 spec.pages)` : ''}`)
63
+ if (pageN !== undefined && pageN !== '') {
64
+ // 2026-09-26:`--page` 此前是**空操作却回执"已记录"**(算了 idx 不用、`for (…){ void p }` 空循环、
65
+ // saveProject 无语义写入),而页级 fidelity 在 DSL/schema/项目 state 里**根本不存在**
66
+ // (docs/01 §5 C5:「页级覆盖字段未做(待定)」)。按本文件既有的诚实先例处理:
67
+ // engine 分支对未实现的 pptxgenjs 就是"明确拒绝,不装作成功"。这里一并不再改任何状态。
68
+ return err(`页级 fidelity 未实现:--page ${pageN} 不会改变任何东西(页级字段在 DSL 与 state 里都不存在,见 docs/01 §5 C5)。
69
+ · 现在只有**会话级**:/ppt fidelity ${v}
70
+ · 若确实要"某几页 strict、其余 free",请明确提出——那是新特性(需先定 DSL 字段与门禁语义),不在修复范围。`)
72
71
  }
73
- return ok(`✓ 内容忠实度=${v}`)
72
+ state.fidelity = v
73
+ await save()
74
+ return ok(`✓ 会话忠实度=${v}(记录/展示用:当前版本它不改变门禁与流程,只回显在工作流提示与状态行)`)
74
75
  }
75
76
  if (cmd === 'review') {
76
77
  const v = (args.split(' ')[0] ?? '').toLowerCase()
@@ -12,6 +12,7 @@
12
12
  */
13
13
  import { writeFile, readFile } from 'node:fs/promises'
14
14
  import { join, isAbsolute } from 'node:path'
15
+ import { createHash } from 'node:crypto'
15
16
  import { zipWrite } from '../zips.js'
16
17
  import { normalizePage } from './layout.js'
17
18
  import { chartData, chartColors, chartIssue } from './svgCharts.js'
@@ -36,6 +37,37 @@ export async function exportPptx(ctx, { out = 'out.pptx', engine = 'pptd' } = {}
36
37
  const lineProof = { exp: 0, out: 0, wrong: [] }
37
38
  UID = 1000
38
39
 
40
+ // 媒体部件命名(2026-09-26 修复"同名静默错图")——**整册共享**(不能按页各算一套,否则同一 srcPath
41
+ // 在不同页会拿到不同部件名,parity 会假红)。要防的事故:旧实现 addMedia 按**完整路径**去重(rId 不同),
42
+ // 但包内部件名与 rels Target 都取 `basename` ⇒ `media/a/logo.png` 与 `media/b/logo.png` 共用一个
43
+ // `ppt/media/logo.png`,后写覆盖先写 —— **成品里一张图被静默换成另一张**,而 parity(只数 `<p:pic>`)与
44
+ // warnings 全绿(2026-09-26 实测复现)。
45
+ // 规则:ASCII 安全且未被占用 ⇒ **原名不动**(既有产物零变化);非 ASCII/空格或重名 ⇒ `stem-<hash8>.<ext>`
46
+ // (hash 取相对路径,跨机器/跨运行确定;`ppt/media/` 是包内部件名,用户看不到)。
47
+ const usedParts = new Map() // srcPath → 部件名
48
+ const takenParts = new Set()
49
+ const renamedMedia = new Map() // srcPath → 改名后的部件名(供报告"不静默")
50
+ const mediaPartName = (srcPath) => {
51
+ if (usedParts.has(srcPath)) return usedParts.get(srcPath)
52
+ const key = String(srcPath)
53
+ const base = key.split(/[\\/]/).pop() || 'image'
54
+ const dot = base.lastIndexOf('.')
55
+ const ext = dot > 0 ? base.slice(dot) : ''
56
+ const stem = dot > 0 ? base.slice(0, dot) : base
57
+ let part = base
58
+ if (!/^[A-Za-z0-9._-]+$/.test(base) || takenParts.has(base)) {
59
+ const slug = stem.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'image'
60
+ const h = createHash('sha1').update(key).digest('hex').slice(0, 8)
61
+ part = `${slug}-${h}${ext}`
62
+ let n = 1
63
+ while (takenParts.has(part)) part = `${slug}-${h}-${n++}${ext}`
64
+ renamedMedia.set(key, part)
65
+ }
66
+ takenParts.add(part)
67
+ usedParts.set(srcPath, part)
68
+ return part
69
+ }
70
+
39
71
  for (const page of ctx.pages) {
40
72
  const els = normalizePage(page, ctx)
41
73
  const shapes = []
@@ -46,7 +78,7 @@ export async function exportPptx(ctx, { out = 'out.pptx', engine = 'pptd' } = {}
46
78
  if (mediaSeen.has(srcPath)) return mediaSeen.get(srcPath)
47
79
  const rId = `rId${media.length + 2}`
48
80
  mediaSeen.set(srcPath, rId)
49
- media.push({ srcPath, rId })
81
+ media.push({ srcPath, rId, part: mediaPartName(srcPath) })
50
82
  return rId
51
83
  }
52
84
 
@@ -136,13 +168,13 @@ export async function exportPptx(ctx, { out = 'out.pptx', engine = 'pptd' } = {}
136
168
  '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>',
137
169
  '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">',
138
170
  '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/>',
139
- ...s.media.map((m) => `<Relationship Id="${m.rId}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/${m.srcPath.split(/[\\/]/).pop()}"/>`),
171
+ ...s.media.map((m) => `<Relationship Id="${m.rId}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/${m.part}"/>`),
140
172
  notesRel,
141
173
  '</Relationships>',
142
174
  ].join('')
143
175
  files[`ppt/slides/_rels/slide${n}.xml.rels`] = srels
144
176
  for (const m of s.media) {
145
- const bn = m.srcPath.split(/[\\/]/).pop()
177
+ const bn = m.part
146
178
  try {
147
179
  files[`ppt/media/${bn}`] = await readFile(join(ctx.dir, m.srcPath))
148
180
  } catch {
@@ -171,13 +203,20 @@ export async function exportPptx(ctx, { out = 'out.pptx', engine = 'pptd' } = {}
171
203
  const notesParts = Object.keys(files).filter((k) => /^ppt\/notesSlides\/notesSlide\d+\.xml$/.test(k))
172
204
  const notesOut = notesParts.length
173
205
  const notesRelOk = slides.filter((s) => s.notes).every((s) => String(files[`ppt/slides/_rels/slide${slides.indexOf(s) + 1}.xml.rels`]).includes(`notesSlide${s.notesNo}.xml`))
206
+ // 媒体 parity(2026-09-26 新增):**不同 srcPath 数 == 包内媒体部件数**。
207
+ // 为什么必须单独加一条:图数量 parity(imagesExp/imagesOut)只数 `<p:pic>`,两张不同的图塌成同一个部件时
208
+ // 它依然是 2/2、ok=true(实测)。而"两张不同的图被写成同一个部件"= 成品里静默用错图,正是本插件最不能出的错。
209
+ const mediaExpSet = new Set()
210
+ for (const s of slides) for (const m of s.media) mediaExpSet.add(m.srcPath)
211
+ const mediaOut = Object.keys(files).filter((k) => /^ppt\/media\//.test(k)).length
174
212
  report.parity = {
175
213
  tablesExp, tablesOut, imagesExp, imagesOut, illegalFrames: illegal,
176
214
  linesExp: lineProof.exp, linesOut: lineProof.out, linesWrong: lineProof.wrong.length,
177
215
  notesExp, notesOut,
216
+ mediaExp: mediaExpSet.size, mediaOut,
178
217
  ok: tablesExp === tablesOut && imagesExp === imagesOut && illegal === 0 &&
179
218
  lineProof.exp === lineProof.out && lineProof.wrong.length === 0 &&
180
- notesExp === notesOut && notesRelOk,
219
+ notesExp === notesOut && notesRelOk && mediaExpSet.size === mediaOut,
181
220
  }
182
221
 
183
222
  // out:绝对路径原样使用;相对路径相对 deck 目录(反馈 E1 ★)
@@ -186,6 +225,8 @@ export async function exportPptx(ctx, { out = 'out.pptx', engine = 'pptd' } = {}
186
225
  report.file = outPath
187
226
  report.slides = slides.length
188
227
  report.engine = engine
228
+ // 改名必须**可见**(不静默):非 ASCII/空格或重名时部件名变了,报告里说明改成了什么。
229
+ if (renamedMedia.size) report.mediaRenamed = [...renamedMedia].map(([src, part]) => `${src} → ppt/media/${part}`)
189
230
  return report
190
231
  }
191
232
 
@@ -6,7 +6,7 @@
6
6
  * - URL 为同源相对路径(/ppt-preview/<token>/pages/deck.html):GUI 页面本身就在 host 上,
7
7
  * 点击/iframe 均同源,不依赖端口探测。
8
8
  */
9
- import { mkdir, rm, copyFile, readFile, stat } from 'node:fs/promises'
9
+ import { mkdir, rm, copyFile, cp, readFile, stat } from 'node:fs/promises'
10
10
  import { existsSync } from 'node:fs'
11
11
  import { join, dirname, extname, normalize } from 'node:path'
12
12
  import { homedir } from 'node:os'
@@ -44,13 +44,14 @@ export async function buildPreview(dir) {
44
44
  await copyFile(join(dir, 'preview', f), join(root, 'pages', f))
45
45
  }
46
46
  await copyFile(join(dir, 'preview', 'deck.html'), join(root, 'pages', 'deck.html'))
47
- // media(含页面背景图;html 以 ../media/ 引用)
47
+ // media(含页面背景图与**子目录**;html 以 ../media/ 引用)
48
+ // 2026-09-26 修复:旧实现只 `readdir` 顶层再逐个 `copyFile` —— 目录条目会被当文件拷,Windows 上直接
49
+ // `EPERM: operation not permitted, copyfile '<deck>\media\sub' -> '<preview>\media\sub'`
50
+ // ⇒ `buildPreview` 整体抛出、`ppt_preview` 直接失败(**不是"缺图"**),预览根还留下半成品。
51
+ // DSL 允许 `media/子目录/x.png`(schema 对 image.src 只校验是字符串、未禁止子目录),文档也从未声明"平铺",
52
+ // 所以这里改成整目录镜像。`cp(recursive)` 保留相对路径 ⇒ 路由层(previewFileFor + join)本就能服务子路径。
48
53
  if (existsSync(join(dir, 'media'))) {
49
- const mediaDir = join(root, 'media')
50
- await mkdir(mediaDir, { recursive: true })
51
- for (const name of await import('node:fs/promises').then((f) => f.readdir(join(dir, 'media')))) {
52
- await copyFile(join(dir, 'media', name), join(mediaDir, name))
53
- }
54
+ await cp(join(dir, 'media'), join(root, 'media'), { recursive: true })
54
55
  }
55
56
  tokens.set(token, root)
56
57
  // 持久化映射(跨进程/重启后链接仍可用):.meta.json 记录源目录
@@ -149,10 +150,8 @@ export function registerPreviewRoute(ctx) {
149
150
  const m = u.pathname.match(/^\/ppt-preview\/([a-zA-Z0-9-]+)\/(.*)$/)
150
151
  if (!m) return notFound(res)
151
152
  const root = await resolveToken(m[1])
152
- const rel = normalize(m[2]).replace(/^([/\\])+/, '')
153
- if (!root || rel.includes('..')) return notFound(res)
154
- const file = join(root, rel)
155
- if (!existsSync(file)) return notFound(res)
153
+ const file = root ? previewFileFor(root, m[2]) : null
154
+ if (!file || !existsSync(file)) return notFound(res)
156
155
  const st = await stat(file)
157
156
  if (st.isDirectory()) return notFound(res)
158
157
  const buf = await readFile(file)
@@ -186,6 +185,30 @@ export function registerPreviewRoute(ctx) {
186
185
  }, 'ppt-studio: preview route')
187
186
  }
188
187
 
188
+ /**
189
+ * 把 `/ppt-preview/<token>/` 之后的**原始请求路径**解析成预览根内的文件;越界或畸形编码返回 null。
190
+ * 抽成导出函数是为了可单测(smoke §54 直接断言,不必起 HTTP)。
191
+ *
192
+ * 2026-09-26 修复两件事:
193
+ * ① **必须 `decodeURIComponent` 一次**:浏览器会把 `media/中文 图.png` 编码成 `%E4%B8%AD…%20…` 再请求,
194
+ * 而 `new URL().pathname` **保留编码形式** ⇒ 旧实现拼出的磁盘路径里含 `%E4%B8%AD`,`existsSync` 必失败。
195
+ * 实测:图片已正确拷进预览根,预览仍 404(导出正常)——中文/空格文件名是中文用户的常态。
196
+ * ② 穿越判定改为**按路径段**看 `..`:旧实现 `rel.includes('..')` 会顺带误杀 `a..b.png` 这类合法文件名。
197
+ */
198
+ export function previewFileFor(root, rawRel) {
199
+ let decoded = String(rawRel ?? '')
200
+ try { decoded = decodeURIComponent(decoded) } catch { return null } // 畸形百分号编码 → 当作不存在
201
+ if (decoded.includes('\0')) return null
202
+ // 任何 `..` **路径段**一律拒绝(保守策略:浏览器发出的请求路径本已规范化,代价为零)。
203
+ // 注意必须**先判再 normalize**——`path.normalize` 会把 `media/../x` 折叠成 `x`,折叠后就看不出越级意图了。
204
+ // 旧实现用 `rel.includes('..')`,会顺带误杀 `a..b.png` 这类合法文件名;这里只拦真正的上级目录段。
205
+ if (decoded.split(/[/\\]+/).some((seg) => seg === '..')) return null
206
+ const norm = normalize(decoded).replace(/^([/\\])+/, '')
207
+ if (!norm) return null
208
+ if (norm.split(/[/\\]+/).some((seg) => seg === '..')) return null // 归一化后仍越界(双保险)
209
+ return join(root, norm)
210
+ }
211
+
189
212
  function notFound(res) {
190
213
  res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' })
191
214
  res.end('not found')
package/lib/scaffold.js CHANGED
@@ -83,7 +83,7 @@ elements:
83
83
  - elementId: img
84
84
  elementType: image
85
85
  bounds: [60, 100, 400, 300]
86
- src: media/pic.png # 相对 deck 根
86
+ src: media/pic.png # 相对 deck 根;子目录与中文/空格名都可以(media/子目录/图.png)
87
87
  fit: cover # cover|contain|fill
88
88
  - elementId: tbl
89
89
  elementType: table
package/lib/templates.js CHANGED
@@ -211,9 +211,12 @@ export async function registerTemplate(dir, opts = {}, { targetDir = userTemplat
211
211
  const ctx3 = await resolveDeck(tplDir)
212
212
  const r3 = await renderDeck(ctx3, { out: '_cleanup-tmp' })
213
213
  const v3 = verifyDeck(r3.layout)
214
+ // 结构化消费(2026-09-26):旧实现 `line.match(/\[✗\] (\w+)/)` 用的是 `\w+`,而所有门禁码都带连字符
215
+ // (out-of-page / text-overflow / theme-conformance…)⇒ 直方图里全是截断码(`out×3`、`text×2`),
216
+ // 而这份报告是模型读的。改用 verifyDeck 新增的结构化 errors。
214
217
  const detail = {}
215
- for (const line of v3.text.split('\n').filter((l) => l.includes('[✗]'))) {
216
- const code = (line.match(/\[✗\] (\w+)/) ?? [])[1] ?? 'other'
218
+ for (const f of v3.errors) {
219
+ const code = f.code ?? 'other'
217
220
  detail[code] = (detail[code] ?? 0) + 1
218
221
  }
219
222
  cleanup = { declared: cleanup.declared, outSafe, remainingErrors: Object.values(detail).reduce((a, b) => a + b, 0), detail }
package/lib/tools.js CHANGED
@@ -541,7 +541,7 @@ export function registerTools(ctx) {
541
541
  const ctx1 = await loadCtx(dir)
542
542
  const r = await renderDeck(ctx1, {})
543
543
  const v = verifyDeck(r.layout)
544
- const errors = v.text.split('\n').filter((l) => l.includes('[✗]')).length
544
+ const errors = v.errors.length
545
545
  const addedNote = added.length
546
546
  ? `\n已写入声明:${added.map((a) => `第${a.page}页 +${a.added}对`).join(',')}`
547
547
  : '\n未发现可自动声明的警告级重叠(全部已声明/内容互压/豁免)。'
@@ -559,13 +559,15 @@ export function registerTools(ctx) {
559
559
  layout = r.layout
560
560
  }
561
561
  let localNote = ''
562
+ let pageScope = null
562
563
  if (pagesSel) {
563
564
  const before = (layout.pages ?? []).length
564
565
  layout = { ...layout, pages: (layout.pages ?? []).filter((p) => pagesSel.has(p.index + 1)) }
565
- localNote = `\n\n> 局部校验:仅审阅第 [${[...pagesSel].sort((a, b) => a - b).join(', ')}] 页(共 ${before} 页;其余页本次未检查)`
566
+ pageScope = { sel: [...pagesSel].sort((a, b) => a - b), covered: pagesSel.size, total: before }
567
+ localNote = `\n\n> 局部校验:仅审阅第 [${pageScope.sel.join(', ')}] 页(共 ${before} 页;其余页本次未检查)`
566
568
  }
567
569
  const v = verifyDeck(layout)
568
- const errors = v.text.split('\n').filter((l) => l.includes('[✗]')).length
570
+ const errors = v.errors.length
569
571
  // M2:实测交叉(D3:实测=终审、估算=预检)
570
572
  let measuredText = ''
571
573
  let measuredErrors = 0
@@ -585,7 +587,16 @@ export function registerTools(ctx) {
585
587
  }
586
588
  const gate = errors + measuredErrors
587
589
  const head = v.text
588
- return `# 数字审阅${localNote}\n${head}${measuredText}\n\n---\n门禁:${gate === 0 ? '✓ 通过' : `✗ ${gate} 个错误(必须清零${measuredErrors ? `,含 M2 实测 ${measuredErrors} 个` : ''}`}\n${pptxSnapshotText}${degradeNote}`
590
+ // 门禁行必须带**审阅范围**(2026-09-26 修复"局部审阅假绿"):pages= 生效时,旧实现把
591
+ // `layout.pages` 先过滤再计数,于是"只审 1 页且该页干净"会打印与整册通过**完全同形**的
592
+ // `门禁:✓ 通过`——而本工具自述写着"不改写 layout.json(防部分校验静默漏检其余页)"
593
+ // (tools.js 的 pages 参数说明 / docs/02 §174)⇒ 用户与模型会得出"整册通过"的错误结论。
594
+ const gateText = gate === 0
595
+ ? (pageScope
596
+ ? `✓ 选中的 ${pageScope.covered} 页通过 —— 其余 ${Math.max(0, pageScope.total - pageScope.covered)} 页**本次未检查**(要整册结论请去掉 pages 重跑)`
597
+ : '✓ 通过')
598
+ : `✗ ${gate} 个错误(必须清零${measuredErrors ? `,含 M2 实测 ${measuredErrors} 个` : ''})`
599
+ return `# 数字审阅${localNote}\n${head}${measuredText}\n\n---\n门禁${pageScope ? '(局部)' : ''}:${gateText}\n${pptxSnapshotText}${degradeNote}`
589
600
  } catch (error) {
590
601
  return `✗ 验证失败:\n${errText(error)}`
591
602
  }
@@ -654,10 +665,16 @@ export function registerTools(ctx) {
654
665
  const phNote = r.mediaPlaceholders?.length
655
666
  ? `\n\n⚠ 缺失媒体 ${r.mediaPlaceholders.length} 个(${r.mediaPlaceholders.join('、')})已用 1×1 白色占位导出——请替换真实图片或删除页面引用(可重新 ppt_import 提取或人工补图)`
656
667
  : ''
668
+ // 媒体部件改名必须可见(2026-09-26):同名冲突/非 ASCII 名会让包内部件名变化——内容不变,
669
+ // 但用户至少要能看到"发生过什么",而不是只看到 parity 数字。
670
+ const renamedNote = r.mediaRenamed?.length
671
+ ? `\n\nℹ 媒体部件改名 ${r.mediaRenamed.length} 个(原因:不同目录同名,或文件名含非 ASCII/空格;` +
672
+ `PPT 里的图片内容与位置不变,改的只是包内部件名):\n${r.mediaRenamed.map((x) => ` - ${x}`).join('\n')}`
673
+ : ''
657
674
  const parityNote = r.parity
658
675
  ? (r.parity.ok
659
- ? `\n✅ 元素 parity 回读:表 ${r.parity.tablesExp}/${r.parity.tablesOut} · 图 ${r.parity.imagesExp}/${r.parity.imagesOut} · 线方向 ${r.parity.linesExp ?? 0}/${r.parity.linesOut ?? 0}(逐条从 OOXML 反推端点自证) · 结构合法(无 PowerPoint 弃帧类嵌套 xfrm)`
660
- : `\n✗ 元素 parity 不一致:表 期望${r.parity.tablesExp}/实际${r.parity.tablesOut} · 图 期望${r.parity.imagesExp}/实际${r.parity.imagesOut} · 线 期望${r.parity.linesExp ?? 0}/实际${r.parity.linesOut ?? 0}/方向错${r.parity.linesWrong ?? 0} · 非法帧 ${r.parity.illegalFrames}——请勿交付,附本输出反馈插件团队`)
676
+ ? `\n✅ 元素 parity 回读:表 ${r.parity.tablesExp}/${r.parity.tablesOut} · 图 ${r.parity.imagesExp}/${r.parity.imagesOut} · 媒体部件 ${r.parity.mediaExp ?? '?'}/${r.parity.mediaOut ?? '?'} · 线方向 ${r.parity.linesExp ?? 0}/${r.parity.linesOut ?? 0}(逐条从 OOXML 反推端点自证) · 结构合法(无 PowerPoint 弃帧类嵌套 xfrm)`
677
+ : `\n✗ 元素 parity 不一致:表 期望${r.parity.tablesExp}/实际${r.parity.tablesOut} · 图 期望${r.parity.imagesExp}/实际${r.parity.imagesOut} · 媒体 期望${r.parity.mediaExp ?? '?'}/实际${r.parity.mediaOut ?? '?'} · 线 期望${r.parity.linesExp ?? 0}/实际${r.parity.linesOut ?? 0}/方向错${r.parity.linesWrong ?? 0} · 非法帧 ${r.parity.illegalFrames}——请勿交付,附本输出反馈插件团队`)
661
678
  : ''
662
679
  // P8(测试反馈):真渲染抽查策略——含复杂元素的页必须覆盖(P1 事故的直接防线)
663
680
  const riskyPages = (ctx0.pages ?? []).map((p, i) => {
@@ -667,7 +684,7 @@ export function registerTools(ctx) {
667
684
  const renderNote = riskyPages.length
668
685
  ? `\nP8 真渲染抽查:建议优先覆盖含 table/chart/image/custGeom 的页 → 第 ${riskyPages.join('、')} 页(每类至少一页;audit 档导出自动全页真渲染)`
669
686
  : ''
670
- return `✓ 已导出(pptd 引擎,${r.slides} 页):${r.file}${fit}${phNote}${parityNote}${renderNote}${floorNote}${chartNote}${await withAudit(r.file)}${engineNote}`
687
+ return `✓ 已导出(pptd 引擎,${r.slides} 页):${r.file}${fit}${phNote}${renamedNote}${parityNote}${renderNote}${floorNote}${chartNote}${await withAudit(r.file)}${engineNote}`
671
688
  } catch (error) {
672
689
  // 自动回退链(C1 决定):auto 且 pptd 硬失败 → 有 python-pptx 则兜底并醒目标注降级(绝不静默)
673
690
  if (eff.allowFallback) {
package/lib/verify.js CHANGED
@@ -452,6 +452,13 @@ export function verifyDeck(layout) {
452
452
  const size = layout.size ?? { width: 960, height: 540 }
453
453
  const theme = layout.theme ?? null
454
454
  let total = 0
455
+ // 结构化 findings(2026-09-26 新增,**纯增量**):消费方(tools.js 的门禁计数、templates.js 的收纳报告)
456
+ // 此前靠 `text.split('\n').filter(l => l.includes('[✗]'))` 与正则 `\[✗\] (\w+)` 反解文本 —— 后者会把
457
+ // `out-of-page` 截成 `out`(`\w` 不含连字符),报告里的错误码直方图全是错的。这里把本就算出来的
458
+ // errors/warns/suggestions 直接带出去,文本格式保持原样(`text` 对用户与旧断言零变化)。
459
+ const allErrors = []
460
+ const allWarns = []
461
+ const allSuggestions = []
455
462
  for (const page of layout.pages ?? []) {
456
463
  const findings = analyzePage(page, size)
457
464
  const suggestions = theme ? aestheticSuggestions(page, size, theme) : []
@@ -461,6 +468,10 @@ export function verifyDeck(layout) {
461
468
  const confirmed = findings.filter((f) => f.severity === 'confirmed')
462
469
  const errors = findings.filter((f) => f.severity === 'error')
463
470
  const warns = findings.filter((f) => f.severity === 'warning')
471
+ const at = { page: page.index + 1, pageName: page.name }
472
+ allErrors.push(...errors.map((f) => ({ ...f, ...at })))
473
+ allWarns.push(...warns.map((f) => ({ ...f, ...at })))
474
+ allSuggestions.push(...suggestions.map((s) => ({ ...s, ...at })))
464
475
  total += findings.length + suggestions.length
465
476
  const confOverlap = confirmed.filter((f) => f.code === 'expected-overlap').length
466
477
  const confOut = confirmed.filter((f) => f.code === 'expected-out-of-safe').length
@@ -475,7 +486,7 @@ export function verifyDeck(layout) {
475
486
  out.push(` - [·] ${s.code}|${s.message}`)
476
487
  }
477
488
  }
478
- return { text: out.join('\n'), total, size }
489
+ return { text: out.join('\n'), total, size, errors: allErrors, warns: allWarns, suggestions: allSuggestions }
479
490
  }
480
491
 
481
492
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-ppt-studio",
3
- "version": "1.1.1",
3
+ "version": "1.1.2",
4
4
  "description": "在 DeepSeek Harness 里做 PPT:说需求 → 逐页制作 → 导出可编辑的 .pptx。含布局检查、Office 真渲染复核,以及可扩展的本地模板库",
5
5
  "//publish": "2026-09-18 定:发 npm,**无 scope** 包名 `dsh-ppt-studio`。**状态(2026-09-26):已发布** —— `dsh-ppt-studio@1.1.0` 在 npm 上(`dist-tags.latest`、public,发布者 `zbsph`),registry 的 tarball 与 GitHub Release 资产**逐字节相同**(sha256 `9303cc5c…`)。首个版本是 `npm publish <发版 tgz>` 直发的;**从下一版起改走 Trusted Publishing(OIDC)全自动通道**:`.github/workflows/publish.yml` 由 release 事件触发 → 下载 Release 资产 → OIDC `npm publish` → 发后自证 integrity —— 零长期 token、零手工(npm 已公告:Bypass-2FA 直发 2027-01 移除)。发布纪律不变:**用发版时那个 tgz**(`.sync-state.json` 的 `localTgz`),**别在 main 前进后跑裸 `npm publish`**(会按当前工作区重新打包 ⇒ 破坏'两条通道同一份字节')。手动补发入口:Actions → Publish to npm → Run workflow。见 README §10 与 docs/05「发布-同步纪律」。",
6
6
  "repository": { "type": "git", "url": "git+https://github.com/zbsph/dsh-ppt-studio.git" },
package/scripts/smoke.mjs CHANGED
@@ -2612,6 +2612,89 @@ ok('npm 发布通道:发布的是**下载下来的 Release 资产**(等 .tgz
2612
2612
  `README npm 位置=${npmAt}|资产 URL 位置=${assetAt}|手册=${manualText53.includes(manualCmd)}|docs/05=${docs05Text.includes(npmCmd)}`)
2613
2613
  }
2614
2614
 
2615
+ // ── 54. 媒体链路:预览与成品必须对齐(2026-09-26 审计修复的三处真缺陷)──────────────────────
2616
+ // ① 不同目录**同名**媒体:旧实现 addMedia 按完整路径去重、但包内部件名与 rels Target 都取 basename
2617
+ // ⇒ 两张不同的图塌成同一个部件(后写覆盖先写)——**成品静默用错图**,而图数量 parity 依旧 2/2、ok=true。
2618
+ // ② 非 ASCII/空格文件名:预览 handler 不解 percent-encoding ⇒ 图已拷进预览根却 404(导出正常)。
2619
+ // ③ `media/子目录/`:旧 buildPreview 把目录条目当文件 copyFile ⇒ EPERM 整体失败(不是"缺图")。
2620
+ // 另附带一条守门:ASCII 且唯一的部件名**必须保持原名**(既有工程产物零变化)。
2621
+ {
2622
+ const { registerTools: regTools54 } = await import('../lib/tools.js')
2623
+ const { previewFileFor } = await import('../lib/preview-server.js')
2624
+ const fsp = await import('node:fs/promises')
2625
+ const mlWork = join(smokeDir, '.tmp-media-link')
2626
+ await fsp.rm(mlWork, { recursive: true, force: true })
2627
+ await fsp.mkdir(join(mlWork, 'pages'), { recursive: true })
2628
+ await fsp.mkdir(join(mlWork, 'media', 'a'), { recursive: true })
2629
+ await fsp.mkdir(join(mlWork, 'media', 'b'), { recursive: true })
2630
+ const pngA = readFileSync(join(root, 'examples', 'smoke', 'media-test.png'))
2631
+ const pngB = readFileSync(join(root, 'templates', 'academic-white', 'preview.png'))
2632
+ await fsp.writeFile(join(mlWork, 'media', 'a', 'logo.png'), pngA)
2633
+ await fsp.writeFile(join(mlWork, 'media', 'b', 'logo.png'), pngB) // 同名、内容不同
2634
+ await fsp.writeFile(join(mlWork, 'media', '中文 图.png'), pngA) // 非 ASCII + 空格
2635
+ await fsp.writeFile(join(mlWork, 'media', 'plain.png'), pngA) // ASCII 且唯一 → 必须保持原名
2636
+ await fsp.writeFile(join(mlWork, 'deck.yaml'), ['version: 1', 'title: media-link', 'size: [960, 540]', 'theme:', ' colors: {primary: "#2563EB"}', 'pages:', ' - pages/01.yaml', ''].join('\n'))
2637
+ const imgEl = (id, x, src) => [` - elementId: ${id}`, ' elementType: image', ` bounds: [${x}, 40, 200, 200]`, ` src: ${src}`]
2638
+ await fsp.writeFile(join(mlWork, 'pages', '01.yaml'), ['pageType: content', 'elements:', ...imgEl('a', 20, 'media/a/logo.png'), ...imgEl('b', 240, 'media/b/logo.png'), ...imgEl('cn', 460, 'media/中文 图.png'), ...imgEl('plain', 680, 'media/plain.png'), ''].join('\n'))
2639
+
2640
+ const expML = await exportPptx(await resolveDeck(mlWork), { out: join(mlWork, 'out.pptx') })
2641
+ const zipML = zipRead(readFileSync(expML.file))
2642
+ const parts = [...zipML.keys()].filter((k) => k.startsWith('ppt/media/'))
2643
+ const bufOf = (k) => Buffer.from(zipML.get(k))
2644
+ const sameA = parts.filter((k) => bufOf(k).equals(pngA)).length
2645
+ const sameB = parts.filter((k) => bufOf(k).equals(pngB)).length
2646
+ const relsML = String(zipML.get('ppt/slides/_rels/slide1.xml.rels'))
2647
+ const targets = [...relsML.matchAll(/Target="\.\.\/media\/([^"]+)"/g)].map((m) => m[1])
2648
+ ok('§54 导出:不同目录**同名**媒体各占一个部件(不再一张图顶掉另一张),且 rels Target 与包内条目一一对应',
2649
+ parts.length === 4 && sameA === 3 && sameB === 1 && targets.length === 4 && targets.every((t) => zipML.has('ppt/media/' + t)),
2650
+ `部件=${JSON.stringify(parts)}|A内容×${sameA}|B内容×${sameB}|rels=${JSON.stringify(targets)}`)
2651
+ ok('§54 导出:ASCII 且唯一的部件名**保持原名**(既有产物零变化),改名只有 2 个且原因可见',
2652
+ parts.includes('ppt/media/plain.png') && parts.includes('ppt/media/logo.png')
2653
+ && (expML.mediaRenamed ?? []).length === 2 && (expML.mediaPlaceholders ?? []).length === 0,
2654
+ `plain 原名=${parts.includes('ppt/media/plain.png')}|改名=${JSON.stringify(expML.mediaRenamed ?? [])}`)
2655
+ ok('§54 导出:媒体 parity 自证(不同 srcPath 数 == 包内媒体部件数)——这条才抓得到"两张图塌成一个部件"',
2656
+ expML.parity?.mediaExp === 4 && expML.parity?.mediaOut === 4 && expML.parity?.ok === true,
2657
+ `mediaExp=${expML.parity?.mediaExp} mediaOut=${expML.parity?.mediaOut} ok=${expML.parity?.ok}`)
2658
+
2659
+ // 预览:子目录必须被递归镜像;非 ASCII 名必须能解析(percent-decode)
2660
+ const pvML = await buildPreview(mlWork)
2661
+ const nested = existsSync(join(pvML.previewRoot, 'media', 'a', 'logo.png'))
2662
+ const cnCopied = existsSync(join(pvML.previewRoot, 'media', '中文 图.png'))
2663
+ const encoded = encodeURIComponent('media/中文 图.png').replace(/%2F/gi, '/')
2664
+ const pEnc = previewFileFor(pvML.previewRoot, encoded)
2665
+ const pNested = previewFileFor(pvML.previewRoot, 'media/a/logo.png')
2666
+ ok('§54 预览:`media/子目录/` 递归镜像 + 非 ASCII 名可服务(旧实现是 EPERM 整体失败 + 中文名 404)',
2667
+ nested && cnCopied && pEnc === join(pvML.previewRoot, 'media', '中文 图.png') && existsSync(pEnc) && existsSync(pNested),
2668
+ `子目录已镜像=${nested}|中文已镜像=${cnCopied}|编码路径解析=${existsSync(pEnc)}`)
2669
+ const trav1 = previewFileFor(pvML.previewRoot, 'media/../secret.txt')
2670
+ const trav2 = previewFileFor(pvML.previewRoot, '../outside.html')
2671
+ const malformed = previewFileFor(pvML.previewRoot, '%E4%B8%AD%ZZ.png')
2672
+ const dotted = previewFileFor(pvML.previewRoot, 'media/a..b.png')
2673
+ ok('§54 预览:`..` 越级与畸形 percent-encoding 仍被拦,而文件名里的 `..` 不被误杀(安全边界没被 decode 破坏)',
2674
+ trav1 === null && trav2 === null && malformed === null && dotted !== null,
2675
+ `media/../secret.txt=${trav1 === null ? '拦' : trav1}|../outside.html=${trav2 === null ? '拦' : trav2}|畸形=${malformed === null ? '拦' : malformed}|a..b.png=${dotted !== null ? '放行' : '误杀'}`)
2676
+
2677
+ // 门禁(局部):真跑 ppt_verify handler,验证"只审部分页"不再打印与整册通过同形的门禁行
2678
+ const gWork = join(smokeDir, '.tmp-verify-scope')
2679
+ await fsp.rm(gWork, { recursive: true, force: true })
2680
+ await fsp.mkdir(join(gWork, 'pages'), { recursive: true })
2681
+ await fsp.writeFile(join(gWork, 'deck.yaml'), ['version: 1', 'title: scope', 'size: [960, 540]', 'theme:', ' colors: {primary: "#2563EB"}', ' textStyles:', ' body: {fontSize: 16, color: "$primary"}', 'pages:', ' - pages/01.yaml', ' - pages/02.yaml', ''].join('\n'))
2682
+ await fsp.writeFile(join(gWork, 'pages', '01.yaml'), ['pageType: content', 'elements:', ' - elementId: bad', ' elementType: shape', ' kind: rect', ' bounds: [900, 520, 100, 60]', ' fill: "#2563EB"', ''].join('\n'))
2683
+ await fsp.writeFile(join(gWork, 'pages', '02.yaml'), ['pageType: content', 'elements:', ' - elementId: ok1', ' elementType: text', ' bounds: [40, 40, 300, 40]', ' content: {text: "干净页", style: "$body"}', ''].join('\n'))
2684
+ const captured = new Map()
2685
+ regTools54({ effect: (cb) => { cb(); return () => {} }, tools: { register: (def) => { captured.set(def.name, def) } }, get: () => undefined })
2686
+ const vTool = captured.get('ppt_verify')
2687
+ const fullOut = String(await vTool.execute({ dir: gWork }, {}))
2688
+ const partOut = String(await vTool.execute({ dir: gWork, pages: '2' }, {}))
2689
+ ok('§54 门禁(局部):pages= 只审部分页时门禁行必须带范围,不得与整册"✓ 通过"同形(工具自述要防的正是静默漏检)',
2690
+ /✗ \d+ 个错误/.test(fullOut)
2691
+ && /门禁(局部):/.test(partOut) && /其余 1 页\*\*本次未检查\*\*/.test(partOut) && !/门禁:✓ 通过/.test(partOut),
2692
+ `整册=「${(fullOut.match(/门禁[^\n]*/) ?? [''])[0].slice(0, 60)}」|局部=「${(partOut.match(/门禁[^\n]*/) ?? [''])[0].slice(0, 80)}」`)
2693
+
2694
+ await fsp.rm(mlWork, { recursive: true, force: true })
2695
+ await fsp.rm(gWork, { recursive: true, force: true })
2696
+ }
2697
+
2615
2698
  // 37.10 【必须是最后一条断言】引用计数自证:文档里 "smoke … N 断言" 必须等于本次真实断言总数。
2616
2699
  // 历史形状:加断言后 README×3 + docs/02 + docs/06×2 + 手册 全靠人工同步,迟早漏一处。
2617
2700
  // 只扫"当前状态"文档(README / 技术报告 / 评审测试矩阵 / 使用手册);docs/01/03/04 里的历史数字是记录,不动。
@@ -97,7 +97,7 @@ DSH 上做 PPT 的工作区:说需求 → 四类任务工作流 → 「数字
97
97
  ## 7. 开发维护(仅改插件时用)
98
98
 
99
99
  - 改码:`src/` → `node scripts/build.mjs` → **重启 host**(插件经 agent preset 会话装配;`dev_reload_package` 只覆盖注入器装配包,且注入器 junction 已存在时会指向旧安装根)。
100
- - 回归:`node scripts/smoke.mjs`(270 断言)→ `node scripts/preflight-1.0.mjs`(发布预检)→ `node scripts/regression-real.mjs`。
100
+ - 回归:`node scripts/smoke.mjs`(276 断言)→ `node scripts/preflight-1.0.mjs`(发布预检)→ `node scripts/regression-real.mjs`。
101
101
  - 跨层验证纪律:**预览层与成品层必须互相验证**——`ppt_render`+`ppt_verify` 只管 HTML/估算层,OOXML 层靠 `ppt_export` 的 parity 自证(表/图/线方向)+ `ppt_visual` 真渲染抽检;只跑单层会漏掉"预览对、成品错"(2026-09-14 连线方向事故)。
102
102
  - 文档链:改需求/决策 → docs/01;改机制 → docs/02;每次 → docs/03;验收 → docs/04;发布前 → docs/06。
103
103
  - 装配:**默认装法是 npm 包名**(`dsh plugin --profile <p> add dsh-ppt-studio`,profile 级、随 profile 启动装配;**桌面端**在插件管理里填同一个包名 `dsh-ppt-studio`;GitHub Release 的资产 URL 与 `scripts/install*.mjs` 是离线/归档备选)。装完**必须重启 host**。排查:`dsh plugin --profile web list` → `dsh --profile web --dump-config` 找 `ppt-studio` 行。preset 插件行只在"离线 junction"场景用,与 bundle 装法**互斥**。