@topmindspace/tms-skills 0.1.3 → 0.1.5

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.
Files changed (32) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +6 -6
  3. package/package.json +2 -2
  4. package/top-ppt-html/README.md +5 -5
  5. package/top-ppt-html/SKILL.md +15 -12
  6. package/top-ppt-html/agents/openai.yaml +2 -2
  7. package/top-ppt-html/assets/examples/2026-09-09-architecture-graphite-dark.html +72 -27
  8. package/top-ppt-html/assets/examples/2026-09-09-presentation-business-blue.html +72 -27
  9. package/top-ppt-html/assets/examples/2026-09-09-research-mckinsey.html +72 -27
  10. package/top-ppt-html/assets/pptx-export.js +69 -25
  11. package/top-ppt-html/assets/style-gallery.html +1 -1
  12. package/top-ppt-html/assets/templates/architecture.html +70 -25
  13. package/top-ppt-html/assets/templates/presentation.html +70 -25
  14. package/top-ppt-html/assets/templates/research.html +70 -25
  15. package/top-ppt-html/package-lock.json +2 -2
  16. package/top-ppt-html/package.json +2 -2
  17. package/top-ppt-html/references/default-surface.md +1 -1
  18. package/top-ppt-html/references/modes.md +1 -1
  19. package/top-ppt-html/references/playbook.md +9 -8
  20. package/top-ppt-html/references/pptx-export.md +1 -1
  21. package/top-ppt-html/references/presentation-craft.md +4 -3
  22. package/top-ppt-html/references/tech-design.md +1 -1
  23. package/top-ppt-html/scripts/audit_skill.py +11 -0
  24. package/top-ppt-html/scripts/build_pptx.js +24 -10
  25. package/top-ppt-html/scripts/cross_verify.py +1 -1
  26. package/top-ppt-html/scripts/negative_tests.py +65 -9
  27. package/top-ppt-html/scripts/quality_gate.py +30 -19
  28. package/top-ppt-html/scripts/render_from_model.py +1 -1
  29. package/top-ppt-html/scripts/sync_runtime.py +10 -4
  30. package/top-ppt-html/scripts/test_feedback_gates.py +180 -0
  31. package/top-ppt-html/scripts/validate_pptx.py +157 -0
  32. package/top-ppt-html/scripts/validate_report.py +31 -1
@@ -2067,6 +2067,7 @@ window.REPORT_MODEL = {
2067
2067
 
2068
2068
  /* __TOPPPT_RUNTIME_START__ */
2069
2069
  /* ══ PPTX 导出运行时(assets/pptx-export.js 的内联副本 · 由 scripts/sync_runtime.py 注入,禁止手改) ══ */
2070
+ /* __TOPPPT_RUNTIME_SHA__:68c55206c33160af */
2070
2071
  /* ═══════════════════════════════════════════════════════════════════════════
2071
2072
  * TopPPT HTML· 浏览器端 PPTX 预览运行时
2072
2073
  * 零依赖、随报告单文件分发;OOXML 序列化器(仅供预览,页面不导出文件)。
@@ -2148,7 +2149,7 @@ var STYLE_DATA_COLORS = {"business-blue":["1a73e8","3526bb","21a199","b77148","9
2148
2149
  var STYLE_DATA_COLORS_DARK = {"business-blue":["8ab4f8","7966e0","88e7e5","d4ad91","cbce83"],"apple-mono":["f5f5f7","2997ff","2dd4bf","ff9f0a","bf5af2"],"mckinsey":["7fb2e5","6f6bdb","8ce3d8","d1a694","cbc586"],"brand-red":["ff5a66","e09e66","e788cb","91d4c2","83b4ce"],"warm-sand":["d9b36a","c2d571","df9391","97b5ce","8e89c7"],"deep-teal":["4fc3b0","7aaecd","97d8a4","c99cac","c29c8f"],"graphite-dark":["4da3ff","6c66e0","88e7dc","d4a691","cec983"],"indigo-violet":["a5a4f0","a967e0","89c3e7","d3c792","aece83"],"spectrum":["8ba0f8","9066e0","88d6e7","d4ba91","bdce83"]};
2149
2150
  var GRID = {"columns":12,"gutter":0.16,"colW":0.8644,"x":[0.6,1.6244,2.6488,3.6732,4.6976,5.722,6.7464,7.7708,8.7952,9.8196,10.844,11.8684,12.733],"y":[2.35,3.27,4.19,5.11,6.03,6.9],"safe":{"top":0.4,"bottom":0.4,"left":0.6,"right":0.6}};
2150
2151
  var TYPO = {"levels":[{"id":"C0","name":"封面/章节幕标题","role":"coverTitle","usage":"封面主标题、章节幕标题"},{"id":"T1","name":"页码/章节徽章","role":"caption","usage":"页码徽章、章节编号"},{"id":"T2","name":"页面主标题/结论标题","role":"h1","usage":"每页顶部结论句(research 行动标题)"},{"id":"T3","name":"副标题/语境说明","role":"lead","usage":"主标题下方导语"},{"id":"T4","name":"模块标题/图表标题","role":"h2","usage":"卡片标题、图表标题、面板标题"},{"id":"T5","name":"证据编号/轻量标签","role":"micro","usage":"Exhibit 编号、状态标签"},{"id":"T6","name":"证据块标题/小节标题","role":"h2","usage":"分栏小标题、证据块标题"},{"id":"T7","name":"正文解释段落","role":"body","usage":"证据解释、管理解读正文、表格正文"},{"id":"T8","name":"结论条文字","role":"body","usage":"so-what 结论条、核心结论框"},{"id":"T9","name":"SO WHAT 标签","role":"caption","usage":"so-what 标签行"},{"id":"T10","name":"SO WHAT 正文/业务含义","role":"body","usage":"so-what 正文、行动含义、表格行动项"},{"id":"T11","name":"图表轴/图例/刻度/微标签","role":"micro","usage":"坐标轴、图例、刻度、单位(禁用于表格正文与完整短句)"},{"id":"T12","name":"图表数据标签","role":"caption","usage":"折线点值、柱形标签、百分比标注"},{"id":"T13","name":"关键 KPI 大数字","role":"h1","usage":"KPI 大数字、hero 数值"},{"id":"T14","name":"注释/口径/来源/页脚","role":"micro","usage":"footnote、来源行、页脚、小页码"}],"tableSemanticRule":{"body":["T7","T10"],"microAllowed":["T11"],"microForbiddenFor":["表格正文","行动项","风险项","解释句","建议句","长项目符号","完整短句"]}};
2151
- var CONT = {"pad":{"card":0.18,"panel":0.2,"cell":0.08,"band":0.16,"soWhat":0.16,"kpi":0.18,"chart":0.1,"head":0.05,"footer":0.05,"node":0.06},"minPad":0.06,"textMetrics":{"lineFactor":1.45,"emAsciiRatio":0.52},"overflowRule":"溢出处置优先级:① 优化内容(列表化/精炼/合并/拆页/换组合版式)→ ② 调整容器尺寸与行宽 → ③ 有限度缩小字号(见 fontShrink)→ ④ 仍装不下则拆页。禁止无限缩字号硬塞,也禁止把「精简」当第一手段砍掉决策必需信息。","fontShrink":{"ladder":[15,14,13.5,13,12.5,12,11.5,11,10.5,10,9.5,9,8.5],"maxShrinkSteps":4,"floorPt":{"presentation":10,"research":9,"architecture":10},"preferContentFirst":true,"warnBelow":9.5},"continuousTextRule":"语义连续的一句话 / so-what 主句 / 结论句 / 表格单元格正文不得拆成多个独立文本框(避免异常空格、断句、基线漂移);局部加粗高亮必须用同一文本框内富文本。"};
2152
+ var CONT = {"pad":{"card":0.18,"panel":0.2,"cell":0.08,"band":0.16,"soWhat":0.16,"kpi":0.18,"chart":0.1,"head":0.05,"footer":0.05,"node":0.06},"minPad":0.06,"textMetrics":{"lineFactor":1.45,"emAsciiRatio":0.52},"overflowRule":"溢出处置优先级(禁静默截断):① 重构承载(列表/卡片/表/图/组合版式)→ ② 拆页/分章(议程→主张→证据卡→明细;01a/01b;信息须完整)→ ③ 换布局形态(V1–V4/多列/卡栅格)→ ④ 有限 fontShrink(阶梯内、模式 floor 以上、最多 4 档)→ ⑤ 禁止:静默截断、砍 so-what/证据/口径、为审美稀疏删决策必需信息。单页字数预算是预警不是删字许可证。","fontShrink":{"ladder":[15,14,13.5,13,12.5,12,11.5,11,10.5,10,9.5,9,8.5],"maxShrinkSteps":4,"floorPt":{"presentation":10,"research":9,"architecture":10},"preferContentFirst":true,"warnBelow":9.5},"continuousTextRule":"语义连续的一句话 / so-what 主句 / 结论句 / 表格单元格正文不得拆成多个独立文本框(避免异常空格、断句、基线漂移);局部加粗高亮必须用同一文本框内富文本。"};
2152
2153
  var REG = {"bar":{"html":"svg-bars","pptx":"native","nativeType":"bar","dataTable":"notes","min":{"vbHeightMin":160}},"hbar":{"html":"svg-hbars","pptx":"native","nativeType":"bar","dataTable":"notes","min":{"vbHeightMin":100}},"stack":{"html":"svg-stack","pptx":"native","nativeType":"bar","dataTable":"notes","min":{"vbHeightMin":160},"nativeOpt":{"barGrouping":"stacked"}},"stackline":{"html":"svg-stackline","pptx":"native","nativeType":"bar","dataTable":"notes","min":{"vbHeightMin":40},"nativeOpt":{"barGrouping":"percentStacked"}},"line":{"html":"svg-line","pptx":"native","nativeType":"line","dataTable":"notes","min":{"vbHeightMin":160}},"dualline":{"html":"svg-dualline","pptx":"native","nativeType":"line","dataTable":"notes","min":{"vbHeightMin":160}},"area":{"html":"svg-area","pptx":"native","nativeType":"area","dataTable":"notes","min":{"vbHeightMin":160}},"donut":{"html":"svg-donut","pptx":"native","nativeType":"doughnut","dataTable":"notes","min":{"pxWidthMin":140}},"multidonut":{"html":"svg-mdonut","pptx":"native","nativeType":"doughnut","dataTable":"notes","min":{"pxWidthMin":140}},"pie":{"html":"svg-pie","pptx":"native","nativeType":"pie","dataTable":"notes","min":{"pxWidthMin":140}},"radar":{"html":"svg-radar","pptx":"native","nativeType":"radar","dataTable":"notes","min":{"pxWidthMin":220}},"scatter":{"html":"svg-scatter","pptx":"native","nativeType":"scatter","dataTable":"notes","min":{"vbHeightMin":180}},"bubble":{"html":"svg-bubble","pptx":"native","nativeType":"bubble","dataTable":"notes","min":{"vbHeightMin":180}},"waterfall":{"html":"svg-wf","pptx":"native","nativeType":"bar","dataTable":"notes","min":{"vbHeightMin":170},"nativeTrick":"stacked+hiddenBase+connector"},"gauge":{"html":"svg-gauge","pptx":"native","nativeType":"doughnut","dataTable":"notes","min":{"pxWidthMin":180},"nativeTrick":"doughnut+firstSliceAng+hiddenRemainder"},"pareto":{"html":"svg-pareto","pptx":"native","nativeType":"bar","dataTable":"notes","min":{"vbHeightMin":180},"nativeTrick":"multiType+bar+line"},"gantt":{"html":"svg-gantt","pptx":"shape","dataTable":"notes","min":{"vbHeightMin":130},"path":{"forbidPresetShapes":true}},"vsbar":{"html":"svg-vsbar","pptx":"shape","dataTable":"notes","min":{"vbHeightMin":100},"path":{"forbidPresetShapes":true}},"progress":{"html":"svg-progress","pptx":"shape","dataTable":"notes","min":{"vbHeightMin":100},"path":{"forbidPresetShapes":true}},"sparkline":{"html":"svg-spark","pptx":"shape","dataTable":"off","min":{"vbHeightMin":20},"path":{"forbidPresetShapes":true}},"funnel":{"html":"svg-funnel","pptx":"shape","dataTable":"notes","min":{"vbHeightMin":160},"path":{"minSamplePoints":8,"forbidPresetShapes":true}},"treemap":{"html":"svg-treemap","pptx":"shape","dataTable":"appendix","min":{"vbHeightMin":170},"path":{"forbidPresetShapes":true}},"marimekko":{"html":"svg-marimekko","pptx":"shape","dataTable":"appendix","min":{"vbHeightMin":160},"path":{"forbidPresetShapes":true}},"boxplot":{"html":"svg-boxplot","pptx":"shape","dataTable":"appendix","min":{"vbHeightMin":180},"path":{"forbidPresetShapes":true}},"network":{"html":"svg-network","pptx":"shape","dataTable":"appendix","min":{"vbHeightMin":200},"path":{"forbidPresetShapes":true}},"sankey":{"html":"svg-sankey","pptx":"shape","dataTable":"appendix","min":{"vbHeightMin":200},"path":{"minSamplePoints":16,"trackBothEdges":true,"forbidPresetShapes":true}},"streamgraph":{"html":"svg-stream","pptx":"shape","dataTable":"appendix","min":{"vbHeightMin":160},"path":{"minSamplePoints":24,"trackBothEdges":true,"forbidPresetShapes":true}},"slope":{"html":"svg-slope","pptx":"shape","dataTable":"notes","min":{"vbHeightMin":180},"path":{"forbidPresetShapes":true}},"dumbbell":{"html":"svg-dumbbell","pptx":"shape","dataTable":"notes","min":{"vbHeightMin":160},"path":{"forbidPresetShapes":true}},"lollipop":{"html":"svg-lollipop","pptx":"shape","dataTable":"notes","min":{"vbHeightMin":140},"path":{"forbidPresetShapes":true}},"dotplot":{"html":"svg-dotplot","pptx":"shape","dataTable":"notes","min":{"vbHeightMin":120},"path":{"forbidPresetShapes":true}},"bulletchart":{"html":"svg-bullet","pptx":"shape","dataTable":"notes","min":{"vbHeightMin":140},"path":{"forbidPresetShapes":true}},"waffle":{"html":"svg-waffle","pptx":"shape","dataTable":"notes","min":{"vbHeightMin":160},"path":{"forbidPresetShapes":true}},"radialbar":{"html":"svg-radialbar","pptx":"shape","dataTable":"notes","min":{"pxWidthMin":200},"path":{"minSamplePoints":16,"forbidPresetShapes":true}},"rose":{"html":"svg-rose","pptx":"shape","dataTable":"notes","min":{"pxWidthMin":180},"path":{"minSamplePoints":16,"forbidPresetShapes":true}},"candlestick":{"html":"svg-candle","pptx":"shape","dataTable":"appendix","min":{"vbHeightMin":180},"path":{"forbidPresetShapes":true}}};
2153
2154
  var DEEP = {"triggers":["高保真","1:1","一比一","精确还原","按图还原","正式交付","不能偏移","像素级","严格对照","逐页验收"],"complexCharts":["sankey","streamgraph","treemap","marimekko","boxplot","network","rose","radialbar","candlestick","funnel"],"checks":{"anchors":true,"containerOverflow":true,"renderCompare":true,"manifest":true},"renderCompare":{"engine":"soffice","fallback":"powerpoint-com","skipIfMissing":true,"dpi":96}};
2154
2155
  var IMG = {"layouts":["full","half","bleed","grid","compare","wall"],"multiLayouts":["grid","compare","wall"],"fitEnum":["cover","contain"],"fitDefault":"cover","formats":["png","jpeg","jpg","webp","svg"],"ratioDefault":{"full":"3:1","half":"4:3","bleed":"21:9","grid":"4:3","compare":"4:3","wall":"1:1"},"ratioCssClass":{"full":"media--r3-1","half":"media--r4-3","bleed":"media--r21-9","grid":"media--r4-3","compare":"media--r4-3","wall":"media--r1-1"},"recommendedSizePx":{"full":"2400×800","half":"1400×1050","bleed":"2560×1100","grid":"900×675","compare":"1400×1050","wall":"480×480"},"maxInlineBytes":1572864,"maxTotalInlineBytes":8388608,"maxPerPage":6,"placeholderLabel":"配图占位","placeholderHintPrefix":"建议 ","placeholderHintSuffix":"px","zeroExternal":true,"qualityRule":"位图优先提供 ≥2× 显示尺寸;截图用 16:9 或 4:3 锁定比例;数据图不要用截图(应用内联 SVG 图表,可随主题变色)。"};
@@ -2345,6 +2346,16 @@ function modeFloorPtA(mode) {
2345
2346
  var m = String(mode || 'presentation');
2346
2347
  return (floors[m] != null) ? floors[m] : 10;
2347
2348
  }
2349
+ function snapFont(pt, ladder, floor) {
2350
+ var lad = (ladder || FONT_LADDER).filter(function (v) { return v >= (floor == null ? 0 : floor); });
2351
+ if (!lad.length) return floor == null ? pt : Math.max(floor, pt);
2352
+ var best = lad[0], bd = Math.abs(lad[0] - pt);
2353
+ for (var i = 1; i < lad.length; i++) {
2354
+ var d = Math.abs(lad[i] - pt);
2355
+ if (d < bd) { bd = d; best = lad[i]; }
2356
+ }
2357
+ return best;
2358
+ }
2348
2359
  function fitFont(items, widthIn, availIn, opts) {
2349
2360
  opts = opts || {};
2350
2361
  var max = (opts.max == null) ? 99 : opts.max, lf = opts.lineFactor || 1.45;
@@ -2689,7 +2700,7 @@ function imageLayoutShapes(sh, S, layout, img, items, points, isPh, fit, y0, y1)
2689
2700
  sh.push(shape('rect', rxI, lyI + rowHI / 2 - 0.06, 0.12, 0.12, S.accent));
2690
2701
  sh.push(txSp(rxI + 0.26, lyI, rwI - 0.3, rowHI - 0.04,
2691
2702
  [[{ t: (pt[0] || '') + ' ', sz: fzI, b: 1, col: S.ink, font: S.font },
2692
- { t: pt[1] || '', sz: fzI - 1, col: S.body, font: S.font }]], { anchor: 't' }));
2703
+ { t: pt[1] || '', sz: snapFont(fzI - 1), col: S.body, font: S.font }]], { anchor: 't' }));
2693
2704
  });
2694
2705
  capY = iy + Math.max(ihh, ih * 0.5);
2695
2706
  } else if (layout === 'bleed') {
@@ -2738,24 +2749,55 @@ function hbarRows(sh, ec, S, y0, y1, dcols, x0, w) {
2738
2749
  }
2739
2750
  /* 竖柱 / 堆叠柱 */
2740
2751
  function vbarShapes(sh, ec, S, x, y, w, h, dcols) {
2741
- var labels = ec.labels || [], values = ec.values || [];
2752
+ /* Multi-series aware (mirrors lineShapes): ec.series[{name,values}] or single ec.values. */
2753
+ var labels = ec.labels || [];
2754
+ var series = (ec.series && ec.series.length)
2755
+ ? ec.series
2756
+ : [{ name: '数值', values: ec.values || [] }];
2742
2757
  var nb = Math.max(1, labels.length);
2758
+ var ns = Math.max(1, series.length);
2743
2759
  var baseY = y + h, plotW = w, step = plotW / nb;
2744
- var bw = Math.min(1.1, step * 0.55);
2745
- var vmax = ec.max || Math.max.apply(null, values.concat([1]));
2746
- var top = values.length ? Math.max.apply(null, values) : 0;
2760
+ var cluster = Math.min(1.1, step * (ns > 1 ? 0.78 : 0.55));
2761
+ var bw = cluster / ns;
2762
+ var all = [];
2763
+ series.forEach(function (se) {
2764
+ (se.values || []).forEach(function (v) { all.push(Number(v) || 0); });
2765
+ });
2766
+ var vmax = ec.max || Math.max.apply(null, all.concat([1]));
2767
+ var top = all.length ? Math.max.apply(null, all) : 0;
2747
2768
  sh.push(shape('rect', x, baseY, plotW, 0.02, S.line));
2748
- values.forEach(function (v, i) {
2749
- var hh = Math.max(0.05, v / vmax * (h - 0.5));
2750
- var bx = x + i * step + (step - bw) / 2;
2751
- var hot = v === top;
2752
- var dc = dcols ? dcols[i % 5] : null;
2753
- sh.push(shape('roundRect', bx, baseY - hh, bw, hh, dc || (hot ? S.accent : S.faint), { adj: 6000 }));
2754
- sh.push(txSp(bx - 0.3, baseY - hh - 0.4, bw + 0.6, 0.32,
2755
- [[{ t: valText(v, ec.unit), sz: 12.5, b: 1, col: hot ? S.accent : S.body, font: S.font }]], { align: 'ctr' }));
2756
- sh.push(txSp(bx - 0.3, baseY + 0.08, bw + 0.6, 0.32,
2757
- [[{ t: labels[i], sz: 10.5, col: S.faint, font: S.font }]], { align: 'ctr' }));
2769
+ series.forEach(function (se, si) {
2770
+ var vals = se.values || [];
2771
+ var col = dcols ? dcols[si % 5] : (si === 0 ? S.accent : S.faint);
2772
+ vals.forEach(function (v, i) {
2773
+ var num = Number(v) || 0;
2774
+ var hh = Math.max(0.05, num / vmax * (h - 0.5));
2775
+ var bx = x + i * step + (step - cluster) / 2 + si * bw;
2776
+ var hot = ns === 1 && num === top;
2777
+ sh.push(shape('roundRect', bx, baseY - hh, Math.max(0.04, bw * 0.92), hh,
2778
+ hot ? (dcols ? dcols[i % 5] : S.accent) : col, { adj: 6000 }));
2779
+ if (ns === 1) {
2780
+ sh.push(txSp(bx - 0.3, baseY - hh - 0.4, bw + 0.6, 0.32,
2781
+ [[{ t: valText(num, ec.unit), sz: 12.5, b: 1, col: hot ? S.accent : S.body, font: S.font }]], { align: 'ctr' }));
2782
+ }
2783
+ });
2758
2784
  });
2785
+ labels.forEach(function (lb, i) {
2786
+ var lx = x + i * step;
2787
+ sh.push(txSp(lx, baseY + 0.08, step, 0.32,
2788
+ [[{ t: lb, sz: 10.5, col: S.faint, font: S.font }]], { align: 'ctr' }));
2789
+ });
2790
+ /* Multi-series legend along top of plot (keeps category labels; avoids blank chart). */
2791
+ if (ns > 1) {
2792
+ var lx2 = x;
2793
+ series.forEach(function (se, si) {
2794
+ var col2 = dcols ? dcols[si % 5] : (si === 0 ? S.accent : S.faint);
2795
+ sh.push(shape('rect', lx2, y, 0.12, 0.12, col2));
2796
+ sh.push(txSp(lx2 + 0.16, y - 0.02, 1.4, 0.24,
2797
+ [[{ t: String(se.name || ('系列' + (si + 1))), sz: 10, col: S.body, font: S.font }]]));
2798
+ lx2 += 1.6;
2799
+ });
2800
+ }
2759
2801
  }
2760
2802
  /* 折线 / 双折线 / 面积 */
2761
2803
  function lineShapes(sh, ec, S, x, y, w, h, dcols) {
@@ -3162,8 +3204,11 @@ function infoApprox(sh, sec, S, y0, y1) {
3162
3204
  totals.push(tt);
3163
3205
  }
3164
3206
  var maxTot = Math.max.apply(null, totals.concat([1]));
3165
- var plotH3 = P.y1 - P.y0;
3166
- var cyMid = (P.y0 + P.y1) / 2;
3207
+ /* Reserve legend INSIDE the plot box so 6-series pages do not crush the annotation band. */
3208
+ var legH = Math.min(1.2, Math.max(0.28, series.length * 0.22 + 0.08));
3209
+ var plotBottom = P.y1 - legH;
3210
+ var plotH3 = Math.max(0.6, plotBottom - P.y0);
3211
+ var cyMid = P.y0 + plotH3 / 2;
3167
3212
  var base = totals.map(function (tv) { return cyMid - tv / maxTot * plotH3 / 2; });
3168
3213
  series.forEach(function (se, si) {
3169
3214
  var upper = base.map(function (bv, i3) { return bv + (values[si][i3] || 0) / maxTot * plotH3 / 2; });
@@ -3173,12 +3218,12 @@ function infoApprox(sh, sec, S, y0, y1) {
3173
3218
  sh.push(shape('rect', MX + i4 * stepX, (base[i4] + base[i4 + 1]) / 2, stepX, hgt, palette[si % palette.length]));
3174
3219
  }
3175
3220
  base = upper;
3176
- sh.push(shape('rect', MX, P.y1 + 0.06 + si * 0.26, 0.12, 0.12, palette[si % palette.length]));
3177
- sh.push(txSp(MX + 0.16, P.y1 + si * 0.26, 1.5, 0.26,
3221
+ sh.push(shape('rect', MX, plotBottom + 0.04 + si * 0.2, 0.12, 0.12, palette[si % palette.length]));
3222
+ sh.push(txSp(MX + 0.16, plotBottom + 0.02 + si * 0.2, 1.5, 0.2,
3178
3223
  [[{ t: String(se.name || ('系列' + (si + 1))), sz: 10, col: S.body, font: S.font }]], { anchor: 'ctr' }));
3179
3224
  });
3180
3225
  labels.forEach(function (lb, i5) {
3181
- sh.push(txSp(MX + (nPts > 1 ? i5 / (nPts - 1) : 0.5) * CW - 0.4, P.y1 - 0.02, 0.8, 0.24,
3226
+ sh.push(txSp(MX + (nPts > 1 ? i5 / (nPts - 1) : 0.5) * CW - 0.4, plotBottom - 0.22, 0.8, 0.2,
3182
3227
  [[{ t: String(lb), sz: 9.5, col: S.faint, font: S.font }]], { align: 'ctr' }));
3183
3228
  });
3184
3229
  }
@@ -3370,8 +3415,8 @@ function slidesOf(model, S) {
3370
3415
  var iy = iR.y;
3371
3416
  var capH2 = img.caption ? 0.32 : 0;
3372
3417
  var ih = Math.max(1.2, iR.h);
3373
- imageLayoutShapes(sh, S, layout, img, items, sec.points || [], isPh, fit, iy, iy + ih);
3374
- if (img.caption) sh.push(txSp(MX, iy + ih + 0.04, CW, capH2, [[{ t: img.caption, sz: 10.5, col: S.faint, font: S.font }]]));
3418
+ var capYImg = imageLayoutShapes(sh, S, layout, img, items, sec.points || [], isPh, fit, iy, iy + ih);
3419
+ if (img.caption) sh.push(txSp(MX, capYImg + 0.04, CW, capH2, [[{ t: img.caption, sz: 10.5, col: S.faint, font: S.font }]]));
3375
3420
  } else if (type === 'donut') {
3376
3421
  var dcn = sec.chart || {};
3377
3422
  if (dcn.labels && dcn.values) donutChart(sh, dcn, S, dcn.colors || donutColors(model.style, S, model.theme));
@@ -3688,7 +3733,7 @@ function slidesOf(model, S) {
3688
3733
  sh.push(shape('rect', x, ly2 + rowHP / 2 - 0.06, 0.12, 0.12, S.accent));
3689
3734
  sh.push(txSp(x + 0.28, ly2, w - 0.4, rowHP - 0.04,
3690
3735
  [[{ t: pt[0] + ' ', sz: fzP, b: 1, col: S.ink, font: S.font },
3691
- { t: pt[1], sz: fzP - 1, col: S.body, font: S.font }]]));
3736
+ { t: pt[1], sz: snapFont(fzP - 1), col: S.body, font: S.font }]]));
3692
3737
  });
3693
3738
  };
3694
3739
  if (lt2 === 'table') {
@@ -3809,7 +3854,7 @@ function slidesOf(model, S) {
3809
3854
  sh.push(shape('rect', ptsR.x, y11 + rowHPt / 2 - P2.markSize / 2, P2.markSize, P2.markSize, S.accent));
3810
3855
  sh.push(txSp(ptsR.x + 0.32, y11, textW, rowHPt - 0.04,
3811
3856
  [[{ t: p[0] + ' ', sz: fzP, b: 1, col: S.ink, font: S.font },
3812
- { t: p[1], sz: fzP - 1, col: S.body, font: S.font }]]));
3857
+ { t: p[1], sz: snapFont(fzP - 1), col: S.body, font: S.font }]]));
3813
3858
  });
3814
3859
  if (hasM) {
3815
3860
  var gx2 = PW - MX - nm2 * mw2;
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "top-ppt-html",
3
- "version": "0.1.0",
3
+ "version": "0.1.5",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "top-ppt-html",
9
- "version": "0.1.0",
9
+ "version": "0.1.5",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "pptxgenjs": "^4.0.0"
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "top-ppt-html",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
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。
@@ -56,7 +57,7 @@ quality_gate.py … --deliver # 同口径
56
57
 
57
58
  ## 长文与信息承载(anti-truncation)
58
59
 
59
- > **硬原则**:大量文字**不得**为了「看起来像稀疏 demo 甲板」而被故意压缩或删除。留白是设计;**删掉证据 / so-what / 口径不是设计**。
60
+ > **硬原则**:大量文字**不得**为了「看起来像稀疏演示样页」而被故意压缩或删除。留白是设计;**删掉证据 / so-what / 口径不是设计**。
60
61
 
61
62
  **Mode A 仍主张一屏一主张**——但「一主张」可以带充分证据:次要卡、列表、表、跟进页。内容密时优先**加结构**,不砍实质。
62
63
 
@@ -108,7 +108,7 @@
108
108
 
109
109
  ## 七、发布态与兼容
110
110
 
111
- - **发布版**:**v0.1**(布局语法 + 模型单写 + 硬门禁 + 风格语汇层)。**版本唯一事实源 = `layout-constants.json` `version`**;`model-schema.json` / `layoutSlots` 同步该值、技能 `package.json` 为 `0.1.x`;`sync_runtime.py` 校验一致。变更流水见仓库根 `CHANGELOG.md`。
111
+ - **发布版**:**v0.1**(布局语法 + 模型单写 + 硬门禁 + 风格语汇层)。**双版本口径**:npm / `metadata.version` = **semver(0.1.x)**;`layout-constants.json` `version` = **布局语法 schema 线(`0.1`)**——`model-schema.json` / `layoutSlots` 同步 schema 线,`sync_runtime.py` 校验 `package.json` 对齐 `0.1.x`。变更流水见仓库根 `CHANGELOG.md`。
112
112
  - **兼容回落**:排版比例尺只有 `modeTypeScale` 一套;未知模式回落 `presentation` 档。环境变量 `TOP_PPT_NODE_EXE` / `TOP_PPT_NODE_PATH`(旧名已移除)。
113
113
  - **文档分工**:本文件管架构与机制;日常规范见 `references/*`,业界对标依据见 `docs/archive/refs/industry-benchmark.md`。
114
114
 
@@ -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,
@@ -45,7 +45,7 @@ SIGNLESS_CHARTS = {'waterfall', 'gauge', 'funnel', 'bullet'}
45
45
  # 文本(值+unit,如 "9pt"),B 通道进 chart part;比对前两端过滤。类别标签(如 "2026Q3")
46
46
  # 不匹配此模式,仍参与一致性检查。
47
47
  NUMERIC_TOKEN = re.compile(
48
- r'^[+-]?[\d.,]+\s*(?:%|‰|pt|pct|pp|TB|GB|MB|KB|k|w|万|亿|元|倍|个|人|天|家|次|项|年'
48
+ r'^[+-]?[\d.,]+\s*(?:%|‰|pt|pct|pp|YB|ZB|EB|PB|TB|GB|MB|KB|kWh|Gbps|k|w|万|亿|元|倍|个|人|天|家|次|项|年'
49
49
  r'|条|笔|台|套|份|款|张|篇|页|件|例|场|轮|艘|辆|吨|米|秒|分|时|档|级)?$')
50
50
 
51
51
 
@@ -20,6 +20,7 @@
20
20
  N12 模型 theme 与 data-theme 矛盾
21
21
  N13 模型 mode 与 data-mode 矛盾
22
22
  N14 内部锚点断裂(链接目标 id 不存在)
23
+ N19 运行时同版本戳被篡改(__TOPPPT_RUNTIME_SHA__)
23
24
  L4 截断迹象(列表项省略号砍义)
24
25
  L5 溢出未拆页
25
26
  L6 混排缺对齐(ALIGN_RHYTHM)
@@ -85,6 +86,25 @@ def expect_layout_qa_fail(name: str, path: Path, keyword: str) -> bool:
85
86
  return ok
86
87
 
87
88
 
89
+ def _runtime_stamp_case() -> bool:
90
+ """N19:篡改 __TOPPPT_RUNTIME_SHA__ → validate_report 必须 FAIL。"""
91
+ base = ROOT / 'assets' / 'examples' / '2026-09-09-research-mckinsey.html'
92
+ if not base.exists():
93
+ print(' [SKIP] N19 运行时同版本戳(基准示例缺失)')
94
+ return True
95
+ txt = base.read_text(encoding='utf-8')
96
+ m = re.search(r'/\* __TOPPPT_RUNTIME_SHA__:([0-9a-f]{16}) \*/', txt)
97
+ if not m:
98
+ print(' [SKIP] N19 运行时同版本戳(示例尚未 sync 出戳;先跑 sync_runtime)')
99
+ return True
100
+ OUT.mkdir(parents=True, exist_ok=True)
101
+ bad = txt[:m.start()] + '/* __TOPPPT_RUNTIME_SHA__:deadbeefdeadbeef */' + txt[m.end():]
102
+ out = OUT / 'N19-bad-runtime-sha.html'
103
+ out.write_text(bad, encoding='utf-8')
104
+ return expect_fail('N19 运行时同版本戳被篡改', out, '运行时同版本戳')
105
+
106
+
107
+
88
108
  def main() -> int:
89
109
  base = ROOT / 'assets' / 'examples' / '2026-09-09-research-mckinsey.html'
90
110
  if not base.exists():
@@ -303,19 +323,55 @@ def main() -> int:
303
323
  ok = _chart_data_case() and ok
304
324
  ok = _title_pattern_case() and ok
305
325
  ok = _font_scale_case() and ok
326
+ ok = _runtime_stamp_case() and ok
306
327
 
307
328
  print('反向验证通过:注入的缺陷都被门禁抓住。' if ok
308
329
  else '反向验证失败:存在抓不到的缺陷,门禁有假阴性。')
309
330
  return 0 if ok else 1
310
331
 
311
332
 
333
+ def _ensure_regression_fixture(name: str = '2026-09-09-research-mckinsey') -> Path | None:
334
+ """Lightweight CI fixture: build one PPTX into dist/regression/ if missing.
335
+ Avoids full regression.py; enough for N4/N5/N7."""
336
+ dest_dir = ROOT / 'dist' / 'regression'
337
+ pptx = dest_dir / f'{name}.pptx'
338
+ if pptx.exists():
339
+ return pptx
340
+ model = ROOT / 'assets' / 'examples' / f'{name}.model.json'
341
+ if not model.exists():
342
+ print(f' [SKIP] regression fixture(缺 model {model.name})')
343
+ return None
344
+ dest_dir.mkdir(parents=True, exist_ok=True)
345
+ build = ROOT / 'scripts' / 'build_pptx.js'
346
+ if not build.exists():
347
+ print(' [SKIP] regression fixture(缺 build_pptx.js)')
348
+ return None
349
+ import os
350
+ env = os.environ.copy()
351
+ nm = ROOT / 'node_modules'
352
+ if nm.is_dir():
353
+ env['NODE_PATH'] = str(nm) + (os.pathsep + env['NODE_PATH'] if env.get('NODE_PATH') else '')
354
+ r = subprocess.run(['node', str(build), str(pptx), f'--model={model}'],
355
+ cwd=str(ROOT), env=env, capture_output=True, text=True,
356
+ encoding='utf-8', errors='replace')
357
+ if r.returncode != 0 or not pptx.exists():
358
+ print(' [SKIP] regression fixture(build_pptx 失败)')
359
+ err = (r.stderr or r.stdout or '')[:200]
360
+ if err:
361
+ print(f' {err}')
362
+ return None
363
+ print(f' [OK] 轻量 regression fixture → {pptx.relative_to(ROOT)}')
364
+ return pptx
365
+
366
+
367
+
312
368
  def _font_scale_case() -> bool:
313
369
  """N7:往 PPTX 里塞一个比例尺外的字号 → FONT_SIZE_OFF_SCALE 必须报。"""
314
370
  name = '2026-09-09-research-mckinsey'
315
- src = ROOT / 'dist' / 'regression' / f'{name}.pptx'
371
+ src = _ensure_regression_fixture(name)
316
372
  model = ROOT / 'assets' / 'examples' / f'{name}.model.json'
317
- if not src.exists() or not model.exists():
318
- print(' [SKIP] N7 字号越出比例尺(需先跑 regression)')
373
+ if src is None or not model.exists():
374
+ print(' [SKIP] N7 字号越出比例尺(需先跑 regression / 缺 node+pptxgenjs)')
319
375
  return True
320
376
  out = OUT / 'N7-off-scale.pptx'
321
377
  patched = False
@@ -375,10 +431,10 @@ def _title_pattern_case() -> bool:
375
431
  def _chart_data_case() -> bool:
376
432
  """N5:篡改模型里的图表数值 → cross_verify 的数值核对必须报不符。"""
377
433
  name = '2026-09-09-research-mckinsey'
378
- pptx = ROOT / 'dist' / 'regression' / f'{name}.pptx'
434
+ pptx = _ensure_regression_fixture(name)
379
435
  mp = ROOT / 'assets' / 'examples' / f'{name}.model.json'
380
- if not pptx.exists() or not mp.exists():
381
- print(' [SKIP] N5 图表数值篡改(需先跑 regression)')
436
+ if pptx is None or not mp.exists():
437
+ print(' [SKIP] N5 图表数值篡改(需先跑 regression / 缺 node+pptxgenjs)')
382
438
  return True
383
439
  sys.path.insert(0, str(ROOT / 'scripts'))
384
440
  try:
@@ -411,10 +467,10 @@ def _chart_data_case() -> bool:
411
467
  def _pptx_notes_case() -> bool:
412
468
  """N4:剥离演讲者备注 → dataTable=notes 的图表数据不可追溯,必须被 strict 抓住。"""
413
469
  name = '2026-09-09-research-mckinsey'
414
- src = ROOT / 'dist' / 'regression' / f'{name}.pptx'
470
+ src = _ensure_regression_fixture(name)
415
471
  model = ROOT / 'assets' / 'examples' / f'{name}.model.json'
416
- if not src.exists() or not model.exists():
417
- print(' [SKIP] N4 备注剥离(需先跑 regression 生成 PPTX)')
472
+ if src is None or not model.exists():
473
+ print(' [SKIP] N4 备注剥离(需先跑 regression / 缺 node+pptxgenjs)')
418
474
  return True
419
475
  stripped = OUT / 'N4-no-notes.pptx'
420
476
  with zipfile.ZipFile(src) as zin, zipfile.ZipFile(stripped, 'w', zipfile.ZIP_DEFLATED) as zout: