@topmindspace/tms-skills 0.1.3 → 0.1.4

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.
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "top-ppt-html",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "private": true,
5
- "description": "TopPPT HTML:报告 / 演示 / 信息架构图技能。单文件 HTML + 版式保真的可编辑 PPTX;布局骨架 P1–P12;模型单写;strict 0/0 交付。",
5
+ "description": "TopPPT HTML:优雅大气的正式场合演讲/汇报甲板。单文件 HTML + 版式保真可编辑 PPTX;布局骨架 P1–P12;模型单写;strict 0/0 交付。",
6
6
  "license": "MIT",
7
7
  "repository": {
8
8
  "type": "git",
@@ -53,7 +53,7 @@
53
53
  ```
54
54
  scaffold_report → 只填 REPORT_MODEL → render_from_model --inplace
55
55
  → validate_report --strict # presentation 自动 --layout-qa
56
- → quality_gate --deliver →(可选)pptx
56
+ → quality_gate --deliver →(用户要 PPTX 时)B 通道 build_pptx → validate_pptx
57
57
  ```
58
58
 
59
59
  ## 长文与密度(Mode A)
@@ -74,7 +74,7 @@
74
74
 
75
75
  ## 模式 A · 演示汇报 `presentation`(assets/templates/presentation.html)
76
76
 
77
- 台上讲给人听,3 秒抓住主张。工艺清单 → `presentation-craft.md`;默认面 → `default-surface.md`。
77
+ 正式场合演讲甲板:优雅、大气、一屏一主张;3 秒抓住结论。工艺 → `presentation-craft.md`;默认面 → `default-surface.md`。**非**咨询密排 dump(那是 Mode B)。
78
78
 
79
79
  要点回顾:
80
80
 
@@ -1,8 +1,8 @@
1
1
  # 作战手册 · Playbook(唯一常读入口 · L1)
2
2
 
3
3
  > **每次生成都读这一份,通常就够了。**
4
- > 本文件是**决策层**:定模式、选页型、选版式组合、选图表、定配色、跑校验。
5
- > **代码、几何、穷举表不在本文件**——需要时按 `SKILL.md` 的 L2 路由读深度文件,**读完即停,不预读**。
4
+ > 决策层:定模式、选页型、选版式组合、选图表、定配色、跑校验——服务**优雅、大气、正式场合**的演讲/汇报甲板(Mode A 默认气质;B/C 按契约,勿把咨询 dump 当 A 默认)。
5
+ > **代码、几何、穷举表不在本文件**——按 `SKILL.md` L2 路由命中再读,**读完即停**;整读大 L2 = **FAIL**。
6
6
 
7
7
  ---
8
8
 
@@ -46,7 +46,7 @@
46
46
  Gate 0 参考图 → 六项问询(1 轮)→ 最小大纲(outline-design「轻量最小集」)→ 1 张规划卡 → scaffold_report.py
47
47
  → **只填 REPORT_MODEL** → render_from_model.py --inplace → validate_report.py --strict → 交付
48
48
  ```
49
- (Fast Mode 跳过 Gate 0/六项,仍须最小大纲;其余同链;**禁止**手改 HTML 正文与模型双写。)
49
+ (Fast Mode 跳过 Gate 0/六项,仍须最小大纲;默认 **HTML-only**,PPTX 显式 opt-in 走 B 通道;**禁止**手改 HTML 正文与模型双写。)
50
50
 
51
51
  **完整路径**(任一命中)——研究 ≥20 页 / 演示 ≥12 页 / 材料量大且杂 / 含未敲定关键判断 / 用户要看框架。
52
52
  ```
@@ -188,10 +188,11 @@ python scripts/validate_pptx.py 2026-09-15-主题.pptx --strict --model=….mode
188
188
  ```
189
189
 
190
190
  **交付前必过**:`validate_report.py --strict` 0/0(**Mode A / presentation 隐含 `--layout-qa`**;research/architecture 不强制);含 PPTX 时 `validate_pptx.py --strict --model=` 0/0。
191
- **推荐一键门禁**:`python scripts/quality_gate.py 报告.html [--pptx x.pptx --model x.model.json]`(presentation 同口径附带 layout-qa;交付加 `--deliver` 出七要素,不要手拼)。
192
- **A/B 全文对等**:`cross_verify.py` 默认 SKIP,仅 `python scripts/cross_verify.py --full-ab` 或 `regression.py --full-ab` 时启用。
191
+ **PPTX 通道**:默认交付只跑 **B 通道**(`build_pptx.js`);**A 通道**(`pptx-export.js` / `gen_channel_a.js`)仅预览与 `cross_verify` / 回归——**勿把 A 产物当交付**。
192
+ **推荐一键门禁**:`python scripts/quality_gate.py 报告.html [--pptx x.pptx --model x.model.json]`(HTML/PPTX/evals **并行**;presentation 附 layout-qa;`--deliver` 出七要素,勿手拼)。
193
+ **A/B 全文对等**:`cross_verify.py` 默认 SKIP,仅 `--full-ab` / `regression.py --full-ab` 启用。
193
194
  **PPTX 冒烟**:`bash scripts/smoke_pptx.sh`(extract_model → build_pptx → validate_pptx --strict)。
194
- **失败自修复**:按输出逐条修 → 重跑,直到 0/0;修复顺序见 `failure-modes.md`。
195
+ **失败自修复**:按输出逐条修 → 重跑至 0/0;顺序见 `failure-modes.md`。
195
196
 
196
197
  ---
197
198
 
@@ -200,7 +201,7 @@ python scripts/validate_pptx.py 2026-09-15-主题.pptx --strict --model=….mode
200
201
  > 常读仍为入口两份;演示路径可加 **L1.5 工艺双件**:`default-surface.md` + `presentation-craft.md`。
201
202
  > 默认生成面:`default-surface.md`(12 页型 + 8 图 + V1–V4)。Mode A 工艺:`presentation-craft.md`。插画:`illustration-layout.md`。
202
203
  >
203
- > **禁止整读(强制走 `extract_snippet.py`)**:`layouts-combo.md` · `components-atoms.md` · `charts-basic.md` · `charts-extended.md` · `content-rules.md` 全文 · `pptx-export.md` · 模式模板 HTML(`assets/templates/*.html`,走 `scaffold_report.py`)。
204
+ > **整读下列文件 = FAIL / 不合格**(强制 `extract_snippet.py`):`layouts-combo.md` · `components-atoms.md` · `charts-basic.md` · `charts-extended.md` · `content-rules.md` 全文 · `pptx-export.md` · 模式模板 HTML(走 `scaffold_report.py`)。
204
205
  > 取码:`python scripts/extract_snippet.py --list` · `--task <名>` · `--chart <类型>` · `--page-type <页型>` · `--file components.md --section 46`(逻辑名自动路由)
205
206
 
206
207
  | 任务 | 只读 |
@@ -218,5 +219,5 @@ python scripts/validate_pptx.py 2026-09-15-主题.pptx --strict --model=….mode
218
219
  | 图标 / 语义速查 | `icons.md` · `--task icons` |
219
220
  | 起骨架用节奏包 | `scaffold_report.py --list-types` → `--preset consulting` 等 |
220
221
 
221
- **纪律**:L0+L1 是默认全部所需(Mode A/Fast 可加 L1.5);上表命中才开 L2;**读完即执行,不预读下一份**;大文件**禁止整读**,一律 `extract_snippet.py`。页型语义槽位见 `layout-constants.json` → `layoutSlots`(双通道同源 IR)。
222
+ **纪律**:L0+L1 默认全部所需(Mode A/Fast 可加 L1.5);命中才开 L2;**读完即执行,不预读**;大文件整读 = **FAIL**,一律 `extract_snippet.py`。页型槽位见 `layout-constants.json` → `layoutSlots`(双通道同源 IR)。
222
223
  **维护者**:改常量/schema/引擎后必跑 `sync_runtime.py` → `audit_styles.py` → `audit_docs.py` → `audit_skill.py` → `build_examples.py` → `regression.py`。
@@ -21,7 +21,7 @@
21
21
  ```
22
22
 
23
23
  - **页面预览**:收到/打开报告的人点 header「预览 PPTX」(快捷键 P)即可查看页序列,无需任何环境。预览模态与精导通道使用**同一序列化语义**(`slidesOf → slideXml`)——**版式与文本所见即所得**;带数据的原生图表在交付 PPTX 中为真 chart part(双击可编辑数据),预览侧为形状近似,**图表形态以精导产物为准**。**页面不直接导出文件**:浏览器端跑不了 strict 质检硬门禁,达不到交付质量;预览模态常显**可复制 AI 提示词**,用户想获得 PPTX 时复制给 AI,由智能体走精导通道生成(`?` 帮助弹窗 / 快捷键 H 有同样说明)。
24
- - **智能体精导**:正式 PPTX 的唯一交付路径——从报告抽模型 → 生成 → **过 strict 质检** → 交付。生成时用户选了「HTML+PPTX」交付格式的,交付时一并生成;交付后用户随时可通过页面提示词回来补生成。
24
+ - **智能体精导(B 通道 · 唯一交付)**:`extract_model.py` → `build_pptx.js` → `validate_pptx.py --strict` 0/0。用户选「HTML+PPTX」或事后要 PPT 时才跑;**不要**用 A 通道(`pptx-export.js` / `gen_channel_a.js`)产物交付——A 仅页面预览与回归双裁判。
25
25
  - **双单源**:页面几何/三模式独立比例尺(`typeScale` 基准 + `modeTypeScale` 模式取值)/页型几何/9 风格 token 全在 `scripts/layout-constants.json`;**页型 DSL schema(字段/必填/适用模式)在 `scripts/model-schema.json`**。通道 B 直接 `require`;页面运行时由 `sync_runtime.py` 注入(同时注入三模板的引擎/UI/运行时内联副本)。**改常量只改 JSON,改 schema 只改 schema JSON,然后跑 `python scripts/sync_runtime.py`**。
26
26
 
27
27
  ### 会场与字号(venue · P1-4)
@@ -2,7 +2,7 @@
2
2
 
3
3
  > **何时读**:Mode A / 正式演示 / 路演 / 演讲 / Fast 演示路径。与 `default-surface.md` 同级常读。
4
4
  > **何时不读**:Mode B 密排、Mode C 架构深读(能力保留,不反向污染 A)。
5
- > **定位**:大气、演讲友好;一屏一主张;图标/图表克制配文——非 gadget deck、非咨询 dump。
5
+ > **定位**:优雅 · 美观 · 大气;正式场合演讲友好;版式/排版/色彩/内容组织优先;图标/图表克制配文——非 gadget deck、非咨询 dump。
6
6
 
7
7
  ## 铁律速查(Hard rules)
8
8
 
@@ -32,7 +32,8 @@
32
32
 
33
33
  ```
34
34
  validate_report.py --strict # presentation 自动隐含 --layout-qa
35
- quality_gate.py … --deliver # 同口径
35
+ quality_gate.py … --deliver # HTML/PPTX/evals 并行;同口径
36
+ # PPTX 交付仅 B 通道 build_pptx.js(A 通道不交付)
36
37
  ```
37
38
 
38
39
  检查:V 契约 · 简单全幅 · 骨架连用 · 截断/溢出未拆页 · 半空卡 · 对齐节奏 · 主张标题 WARN · 核图多样性 · WCAG。
@@ -210,6 +210,17 @@ def main() -> int:
210
210
  'SKILL.md 未声明交互轮次上限', '已声明(≤3 轮)')
211
211
  chk('⑤ 必读文件预算', bool(re.search(r'必读.{0,6}上限|必读文件数|只读当前需要的|不预读', sk)),
212
212
  'SKILL.md 未声明必读文件预算/不预读纪律', '已声明(不预读纪律)')
213
+ # ⑤b 反过读硬门禁文案(整读大 L2 = FAIL)+ B 通道交付口径
214
+ ok_snip = bool(re.search(r'extract_snippet', sk)) and bool(
215
+ re.search(r'整读.{0,40}(?:FAIL|不合格)|(?:FAIL|不合格).{0,40}整读', sk))
216
+ chk('⑤ extract_snippet 整读=FAIL', ok_snip,
217
+ 'SKILL.md 须同时声明 extract_snippet 与「整读…FAIL/不合格」',
218
+ '已声明 FAIL 口径')
219
+ ok_b = bool(re.search(r'B\s*通道|build_pptx', sk)) and bool(
220
+ re.search(r'A\s*通道|pptx-export|cross_verify', sk))
221
+ chk('⑤ B 通道交付 / A 预览', ok_b,
222
+ 'SKILL.md 须区分 B 通道交付与 A 通道预览/cross_verify',
223
+ '已区分')
213
224
 
214
225
  # ⑥ 引用完整性
215
226
  missing: list[str] = []
@@ -174,6 +174,17 @@ function modeFloorPt() {
174
174
  const f = FS_POLICY.floorPt[m];
175
175
  return f != null ? f : 10;
176
176
  }
177
+ function snapFont(pt, ladder, floor) {
178
+ /* Snap arbitrary pt to nearest ladder step (≥ floor). fitFont + callers (fz-1) must use this. */
179
+ const lad = (ladder || FONT_LADDER).filter(v => v >= (floor == null ? 0 : floor));
180
+ if (!lad.length) return floor == null ? pt : Math.max(floor, pt);
181
+ let best = lad[0], bd = Math.abs(lad[0] - pt);
182
+ for (let i = 1; i < lad.length; i++) {
183
+ const d = Math.abs(lad[i] - pt);
184
+ if (d < bd) { bd = d; best = lad[i]; }
185
+ }
186
+ return best;
187
+ }
177
188
  function fitFont(items, widthIn, availIn, opts) {
178
189
  opts = opts || {};
179
190
  const floor = opts.floor != null ? opts.floor : modeFloorPt();
@@ -536,7 +547,7 @@ function imageLayoutShapes(s, layout, img, items, points, isPh, fit, y0, y1) {
536
547
  const ptsI = points || [];
537
548
  const rowHI = fitRowH(ih - 0.1, ptsI.length, IM.noteRowH || 0.62, 0.3);
538
549
  const fzI = fitFont(ptsI.map(p => (p[0] || '') + (p[1] || '')), rwI - 0.3, ih, { max: 14, gapFactor: 0.6 });
539
- const fz = sz(fzI), fzSm = sz(fzI - 1);
550
+ const fz = sz(fzI), fzSm = sz(snapFont(fzI - 1));
540
551
  ptsI.forEach((pt, i) => {
541
552
  const lyI = iy + i * rowHI;
542
553
  s.addShape('rect', { x: rxI, y: lyI + rowHI / 2 - 0.06, w: 0.12, h: 0.12,
@@ -1490,8 +1501,11 @@ function infoStreamgraph(s, sec, y0, y1) {
1490
1501
  totals.push(t);
1491
1502
  }
1492
1503
  const maxTot = Math.max.apply(null, totals.concat([1]));
1493
- const plotH = P.y1 - P.y0;
1494
- const cyMid = (P.y0 + P.y1) / 2;
1504
+ /* Reserve legend INSIDE the plot box — 6 series must not crush the annotation band. */
1505
+ const legH = Math.min(1.2, Math.max(0.28, series.length * 0.22 + 0.08));
1506
+ const plotBottom = P.y1 - legH;
1507
+ const plotH = Math.max(0.6, plotBottom - P.y0);
1508
+ const cyMid = P.y0 + plotH / 2;
1495
1509
  const xAt = (i) => MX + (nPts > 1 ? i / (nPts - 1) : 0.5) * CW;
1496
1510
  const yAt = (i) => cyMid - (totals[i] / maxTot) * plotH / 2;
1497
1511
  const palette = [STYLE.accent, STYLE.faint, STYLE.body, STYLE.line, STYLE.soft, STYLE.surface];
@@ -1512,14 +1526,14 @@ function infoStreamgraph(s, sec, y0, y1) {
1512
1526
  }
1513
1527
  ribbonRects(s, pts, palette[si % palette.length]);
1514
1528
  const lx = MX;
1515
- s.addShape('rect', { x: lx, y: P.y1 + 0.06 + si * 0.26, w: 0.12, h: 0.12,
1529
+ s.addShape('rect', { x: lx, y: plotBottom + 0.04 + si * 0.2, w: 0.12, h: 0.12,
1516
1530
  fill: { color: palette[si % palette.length] }, line: { type: 'none' } });
1517
- s.addText(String(se.name || ('系列' + (si + 1))), { x: lx + 0.16, y: P.y1 + si * 0.26, w: 1.5, h: 0.26,
1531
+ s.addText(String(se.name || ('系列' + (si + 1))), { x: lx + 0.16, y: plotBottom + 0.02 + si * 0.2, w: 1.5, h: 0.2,
1518
1532
  fontFace: STYLE.font, fontSize: sz(10), color: STYLE.body, valign: 'middle' });
1519
1533
  base = upper;
1520
1534
  });
1521
1535
  labels.forEach((lb, i) => {
1522
- s.addText(String(lb), { x: xAt(i) - 0.4, y: P.y1 - 0.02, w: 0.8, h: 0.24, align: 'center',
1536
+ s.addText(String(lb), { x: xAt(i) - 0.4, y: plotBottom - 0.22, w: 0.8, h: 0.2, align: 'center',
1523
1537
  fontFace: STYLE.font, fontSize: sz(9.5), color: STYLE.faint });
1524
1538
  });
1525
1539
  }
@@ -1745,8 +1759,8 @@ CONTENT.sections.forEach((sec) => {
1745
1759
  const iy = ireg.y;
1746
1760
  const capH = img.caption ? 0.32 : 0;
1747
1761
  const ih = Math.max(1.2, ireg.h);
1748
- imageLayoutShapes(s, layout, img, items, sec.points || [], isPh, fit, iy, iy + ih);
1749
- if (img.caption) s.addText(img.caption, { x: MX, y: iy + ih + 0.04, w: CW, h: capH,
1762
+ const capYImg = imageLayoutShapes(s, layout, img, items, sec.points || [], isPh, fit, iy, iy + ih);
1763
+ if (img.caption) s.addText(img.caption, { x: MX, y: capYImg + 0.04, w: CW, h: capH,
1750
1764
  fontFace: STYLE.font, fontSize: sz(10.5), color: STYLE.faint });
1751
1765
  } else if (type === 'donut') {
1752
1766
  const dcn = sec.chart || {};
@@ -2133,7 +2147,7 @@ CONTENT.sections.forEach((sec) => {
2133
2147
  const ly0 = bodyY, availP = bodyBottom - ly0;
2134
2148
  const rowH = fitRowH(availP, pts.length, SP.rowH, 0.3);
2135
2149
  const fzB = fitFont(pts.map(p => (p[0] || '') + (p[1] || '')), w - 0.4, availP, { max: 14, gapFactor: 0.6 });
2136
- const fz = sz(fzB), fzSm = sz(fzB - 1);
2150
+ const fz = sz(fzB), fzSm = sz(snapFont(fzB - 1));
2137
2151
  pts.forEach((pt, i) => {
2138
2152
  const ly = ly0 + i * rowH;
2139
2153
  s.addShape('rect', { x, y: ly + rowH / 2 - 0.06, w: 0.12, h: 0.12, fill: { color: STYLE.accent }, line: { type: 'none' } });
@@ -2277,7 +2291,7 @@ CONTENT.sections.forEach((sec) => {
2277
2291
  const rowH = fitRowH(avail, pts.length, P.rowH, 0.3);
2278
2292
  const fzB = fitFont(pts.map(p => (p[0] || '') + (p[1] || '')), textW - 0.32, avail,
2279
2293
  { max: 15, gapFactor: 0.55 });
2280
- const fz = sz(fzB), fzSm = sz(fzB - 1);
2294
+ const fz = sz(fzB), fzSm = sz(snapFont(fzB - 1));
2281
2295
  pts.forEach((p, i) => {
2282
2296
  const y = bodyY + i * rowH;
2283
2297
  s.addShape('rect', { x: MX, y: y + rowH / 2 - P.markSize / 2, w: P.markSize, h: P.markSize,
@@ -16,7 +16,7 @@
16
16
  ② 无 LLM 的 rubric 启发式(content/layout/chart/infographic/tone 五维)
17
17
  ③ --deliver:按 SKILL.md「交付说明」七要素(路径/字节数/模式/风格/篇幅/格式/校验+引用)
18
18
  生成结构化交付说明——七要素缺一即 gate FAIL,杜绝手拼遗漏
19
- 交付时一键跑完,避免「只过了结构、内容质量靠自觉」。
19
+ 交付时一键跑完;互不依赖的子进程门禁(HTML / PPTX / evals)并行执行以缩短墙钟。
20
20
  """
21
21
  from __future__ import annotations
22
22
 
@@ -26,6 +26,7 @@ import re
26
26
  import subprocess
27
27
  import sys
28
28
  import time
29
+ from concurrent.futures import ThreadPoolExecutor, as_completed
29
30
  from pathlib import Path
30
31
 
31
32
  try:
@@ -242,33 +243,43 @@ def main() -> int:
242
243
  print(f'交付质量门禁 · {html.name}')
243
244
  print('-' * 60)
244
245
 
245
- # ① HTML strict(Mode A / presentation 自动附带 --layout-qa)
246
+ # ①–③ 互不依赖子进程并行(HTML strict / PPTX strict / evals)
246
247
  head_snip = html.read_text(encoding='utf-8')[:2500]
247
248
  mode_m = re.search(r'data-mode="([^"]+)"', head_snip)
248
249
  mode = mode_m.group(1) if mode_m else 'presentation'
250
+ jobs: list[tuple[str, list[str], str | None]] = []
251
+ # (name, cmd, fixed_note) — fixed_note 非空则覆盖尾部摘要
249
252
  vcmd = [sys.executable, str(ROOT / 'scripts' / 'validate_report.py'), str(html), '--strict']
250
253
  if mode == 'presentation':
251
254
  vcmd.append('--layout-qa')
252
- rc, out, el = run(vcmd)
253
- gate_name = 'validate_report --strict' + (' --layout-qa' if mode == 'presentation' else '')
254
- tail = [ln for ln in out.splitlines() if ln.startswith('PASS ') or ln.startswith('结论')]
255
- gate(gate_name, rc == 0, tail[-1] if tail else f'exit {rc}', el)
256
-
257
- # ② PPTX strict(可选)
255
+ gate_html = 'validate_report --strict' + (' --layout-qa' if mode == 'presentation' else '')
256
+ jobs.append((gate_html, vcmd, None))
258
257
  if args.pptx:
259
- cmd = [sys.executable, str(ROOT / 'scripts' / 'validate_pptx.py'), args.pptx, '--strict']
258
+ pcmd = [sys.executable, str(ROOT / 'scripts' / 'validate_pptx.py'), args.pptx, '--strict']
260
259
  if args.model:
261
- cmd += ['--model=' + args.model]
262
- rc, out, el = run(cmd)
263
- tail = [ln for ln in out.splitlines() if ln.startswith('PASS ') or ln.startswith('结论')]
264
- gate('validate_pptx --strict', rc == 0, tail[-1] if tail else f'exit {rc}', el)
265
-
266
- # ③ evals 确定性
267
- cmd = [sys.executable, str(ROOT / 'evals' / 'run_evals.py'), '--score', str(html)]
260
+ pcmd += ['--model=' + args.model]
261
+ jobs.append(('validate_pptx --strict', pcmd, None))
262
+ ecmd = [sys.executable, str(ROOT / 'evals' / 'run_evals.py'), '--score', str(html)]
268
263
  if args.trace:
269
- cmd += ['--trace', args.trace]
270
- rc, out, el = run(cmd)
271
- gate('evals --score', rc == 0, '确定性 + 效率检查', el)
264
+ ecmd += ['--trace', args.trace]
265
+ jobs.append(('evals --score', ecmd, '确定性 + 效率检查'))
266
+
267
+ results: dict[str, tuple[int, str, float, str | None]] = {}
268
+ with ThreadPoolExecutor(max_workers=max(1, len(jobs))) as pool:
269
+ futs = {pool.submit(run, cmd): (name, note) for name, cmd, note in jobs}
270
+ for fut in as_completed(futs):
271
+ name, note = futs[fut]
272
+ rc, out, el = fut.result()
273
+ results[name] = (rc, out, el, note)
274
+
275
+ # 按 jobs 声明顺序输出(稳定可读;墙钟已并行)
276
+ for name, _cmd, _note in jobs:
277
+ rc, out, el, note = results[name]
278
+ if note is not None:
279
+ gate(name, rc == 0, note, el)
280
+ else:
281
+ tail = [ln for ln in out.splitlines() if ln.startswith('PASS ') or ln.startswith('结论')]
282
+ gate(name, rc == 0, tail[-1] if tail else f'exit {rc}', el)
272
283
 
273
284
  # ④ rubric 启发式
274
285
  rubric = heuristic_rubric(html)
@@ -0,0 +1,180 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Regression gates for usage-feedback defects (validator + static engine checks)."""
4
+ from __future__ import annotations
5
+
6
+ import re
7
+ import sys
8
+ from pathlib import Path
9
+ from xml.etree import ElementTree as ET
10
+
11
+ ROOT = Path(__file__).resolve().parent
12
+ sys.path.insert(0, str(ROOT))
13
+ import validate_pptx as V # noqa: E402
14
+
15
+ EMU = 914400
16
+ NS = V.NS
17
+ fails: list[str] = []
18
+
19
+
20
+ def ok(name: str, cond: bool, detail: str = "") -> None:
21
+ status = "PASS" if cond else "FAIL"
22
+ print(f" [{status}] {name}" + (f" — {detail}" if detail and not cond else ""))
23
+ if not cond:
24
+ fails.append(name)
25
+
26
+
27
+ def test_shape_bounds_reads_p_xfrm() -> None:
28
+ xml = (
29
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
30
+ f'<p:graphicFrame xmlns:a="{NS["a"]}" xmlns:p="{NS["p"]}">'
31
+ "<p:xfrm>"
32
+ f'<a:off x="{int(0.6 * EMU)}" y="{int(2.5 * EMU)}"/>'
33
+ f'<a:ext cx="{int(8.5 * EMU)}" cy="{int(3.5 * EMU)}"/>'
34
+ "</p:xfrm>"
35
+ "</p:graphicFrame>"
36
+ )
37
+ el = ET.fromstring(xml)
38
+ box = V.shape_bounds(el)
39
+ ok("1 shape_bounds reads p:xfrm", box is not None and box[2] > 0, f"got {box}")
40
+
41
+
42
+ def test_shape_bounds_still_reads_a_xfrm() -> None:
43
+ xml = (
44
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
45
+ f'<p:sp xmlns:a="{NS["a"]}" xmlns:p="{NS["p"]}">'
46
+ "<p:spPr><a:xfrm>"
47
+ f'<a:off x="{EMU}" y="{EMU}"/>'
48
+ f'<a:ext cx="{2 * EMU}" cy="{EMU}"/>'
49
+ "</a:xfrm></p:spPr></p:sp>"
50
+ )
51
+ el = ET.fromstring(xml)
52
+ box = V.shape_bounds(el)
53
+ ok("1b shape_bounds reads a:xfrm", box is not None and box[0] == EMU)
54
+
55
+
56
+ def _text_shape(paras, x=0.6, y=2.5, w=4.0, h=0.6):
57
+ parts = []
58
+ for text, pt in paras:
59
+ parts.append(
60
+ f'<a:p><a:r><a:rPr sz="{int(pt * 100)}"/><a:t>{text}</a:t></a:r></a:p>'
61
+ )
62
+ xml = (
63
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
64
+ f'<p:sp xmlns:a="{NS["a"]}" xmlns:p="{NS["p"]}">'
65
+ "<p:spPr><a:xfrm>"
66
+ f'<a:off x="{int(x * EMU)}" y="{int(y * EMU)}"/>'
67
+ f'<a:ext cx="{int(w * EMU)}" cy="{int(h * EMU)}"/>'
68
+ "</a:xfrm></p:spPr>"
69
+ f'<p:txBody>{"".join(parts)}</p:txBody></p:sp>'
70
+ )
71
+ return ET.fromstring(xml)
72
+
73
+
74
+ def test_text_overflow_vertical() -> None:
75
+ paras = [(f"这是第{i}段足够长的中文内容用来累计行高", 12.0) for i in range(8)]
76
+ shape = _text_shape(paras, h=0.55)
77
+ issues = V.text_overflow_vertical_check(shape, 1)
78
+ codes = [i["code"] for i in issues]
79
+ ok(
80
+ "2 TEXT_OVERFLOW_VERTICAL fires on multi-segment cumulative",
81
+ "TEXT_OVERFLOW_VERTICAL" in codes,
82
+ f"codes={codes}",
83
+ )
84
+
85
+
86
+ def test_annotation_band_overlap() -> None:
87
+ xml = (
88
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
89
+ f'<p:sp xmlns:a="{NS["a"]}" xmlns:p="{NS["p"]}">'
90
+ "<p:spPr><a:xfrm>"
91
+ f'<a:off x="{int(0.6 * EMU)}" y="{int(5.5 * EMU)}"/>'
92
+ f'<a:ext cx="{int(4 * EMU)}" cy="{int(1.4 * EMU)}"/>'
93
+ "</a:xfrm></p:spPr></p:sp>"
94
+ )
95
+ el = ET.fromstring(xml)
96
+ issues = V.annotation_band_overlap_check([el], 1, int(7.5 * EMU))
97
+ codes = [i["code"] for i in issues]
98
+ ok(
99
+ "3 ANNOTATION_BAND_OVERLAP fires when content crushes band",
100
+ "ANNOTATION_BAND_OVERLAP" in codes,
101
+ f"codes={codes}",
102
+ )
103
+
104
+
105
+ def test_font_size_not_snapped() -> None:
106
+ xml = (
107
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
108
+ f'<p:sld xmlns:a="{NS["a"]}" xmlns:p="{NS["p"]}">'
109
+ "<p:cSld><p:spTree><p:sp><p:txBody>"
110
+ '<a:p><a:r><a:rPr sz="1130"/><a:t>OffLadderSampleTextHere</a:t></a:r></a:p>'
111
+ "</p:txBody></p:sp></p:spTree></p:cSld></p:sld>"
112
+ )
113
+ root = ET.fromstring(xml)
114
+ issues = V.font_size_snap_check(root, 1)
115
+ codes = [i["code"] for i in issues]
116
+ ok(
117
+ "4 FONT_SIZE_NOT_SNAPPED fires on off-ladder size",
118
+ "FONT_SIZE_NOT_SNAPPED" in codes,
119
+ f"codes={codes}",
120
+ )
121
+
122
+
123
+ def test_engine_static() -> None:
124
+ export = (ROOT.parent / "assets" / "pptx-export.js").read_text(encoding="utf-8")
125
+ build = (ROOT.parent / "scripts" / "build_pptx.js").read_text(encoding="utf-8")
126
+
127
+ m = re.search(r"function vbarShapes\([\s\S]*?\n\}", export)
128
+ body = m.group(0) if m else ""
129
+ ok("B vbarShapes handles ec.series", "ec.series" in body and "series.forEach" in body)
130
+
131
+ ok(
132
+ "C A-channel streamgraph reserves legend inside plot",
133
+ "Reserve legend INSIDE" in export and "plotBottom" in export,
134
+ )
135
+ ok(
136
+ "C B-channel infoStreamgraph reserves legend inside plot",
137
+ "Reserve legend INSIDE" in build and "plotBottom" in build,
138
+ )
139
+
140
+ ok(
141
+ "D A-channel caption uses capYImg from imageLayoutShapes",
142
+ "capYImg" in export
143
+ and re.search(r"capYImg\s*=\s*imageLayoutShapes", export) is not None,
144
+ )
145
+ ok(
146
+ "D B-channel caption uses capYImg from imageLayoutShapes",
147
+ "capYImg" in build
148
+ and re.search(r"capYImg\s*=\s*imageLayoutShapes", build) is not None,
149
+ )
150
+
151
+ cards = re.search(
152
+ r"else if \(type === 'cards'\) \{[\s\S]*?else if \(type === 'split'\)",
153
+ export,
154
+ )
155
+ cards_body = cards.group(0) if cards else ""
156
+ ok(
157
+ "A A-channel cards uses sz: N object props (not bare sz())",
158
+ "sz: 12" in cards_body
159
+ and not re.search(r"(?<![\w.])sz\s*\(\s*12\s*\)", cards_body),
160
+ )
161
+ ok(
162
+ "A modeSize applied in txSp serialization",
163
+ "function modeSize" in export and "modeSize(" in export,
164
+ )
165
+
166
+
167
+ def main() -> int:
168
+ print("feedback-gates regression")
169
+ test_shape_bounds_reads_p_xfrm()
170
+ test_shape_bounds_still_reads_a_xfrm()
171
+ test_text_overflow_vertical()
172
+ test_annotation_band_overlap()
173
+ test_font_size_not_snapped()
174
+ test_engine_static()
175
+ print(f"\n{len(fails)} failed" if fails else "\nAll feedback gates PASS")
176
+ return 1 if fails else 0
177
+
178
+
179
+ if __name__ == "__main__":
180
+ sys.exit(main())