@topmindspace/tms-skills 0.1.0 → 0.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.
Files changed (102) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/README.md +9 -3
  3. package/bin/tms-skills.js +200 -160
  4. package/package.json +12 -9
  5. package/top-ppt-html/README.md +162 -254
  6. package/top-ppt-html/SKILL.md +126 -111
  7. package/top-ppt-html/assets/examples/2026-09-09-architecture-graphite-dark.html +3926 -3926
  8. package/top-ppt-html/assets/examples/2026-09-09-architecture-graphite-dark.model.json +167 -167
  9. package/top-ppt-html/assets/examples/2026-09-09-presentation-business-blue.html +4325 -4325
  10. package/top-ppt-html/assets/examples/2026-09-09-presentation-business-blue.model.json +320 -320
  11. package/top-ppt-html/assets/examples/2026-09-09-research-mckinsey.html +5527 -5527
  12. package/top-ppt-html/assets/examples/2026-09-09-research-mckinsey.model.json +913 -913
  13. package/top-ppt-html/assets/pptx-export.js +1944 -1944
  14. package/top-ppt-html/assets/style-gallery.html +589 -589
  15. package/top-ppt-html/assets/templates/architecture.html +3728 -3728
  16. package/top-ppt-html/assets/templates/engine.css +840 -840
  17. package/top-ppt-html/assets/templates/presentation.html +3738 -3738
  18. package/top-ppt-html/assets/templates/research.html +4017 -4017
  19. package/top-ppt-html/assets/templates/ui.js +520 -520
  20. package/top-ppt-html/assets/theme-overview-architecture.png +0 -0
  21. package/top-ppt-html/assets/theme-overview-research.png +0 -0
  22. package/top-ppt-html/assets/theme-overview.png +0 -0
  23. package/top-ppt-html/evals/prompts.csv +15 -15
  24. package/top-ppt-html/evals/rubric.schema.json +25 -25
  25. package/top-ppt-html/evals/run_evals.py +220 -220
  26. package/top-ppt-html/evals/trace.example.json +16 -16
  27. package/top-ppt-html/package-lock.json +186 -0
  28. package/top-ppt-html/package.json +11 -11
  29. package/top-ppt-html/references/charts-basic.md +624 -624
  30. package/top-ppt-html/references/charts-discipline.md +110 -108
  31. package/top-ppt-html/references/charts-extended.md +482 -482
  32. package/top-ppt-html/references/charts.md +37 -28
  33. package/top-ppt-html/references/components-atoms.md +624 -624
  34. package/top-ppt-html/references/components.md +30 -30
  35. package/top-ppt-html/references/content-rules.md +510 -490
  36. package/top-ppt-html/references/default-surface.md +61 -0
  37. package/top-ppt-html/references/design-system.md +478 -478
  38. package/top-ppt-html/references/failure-modes.md +227 -214
  39. package/top-ppt-html/references/high-fidelity.md +127 -127
  40. package/top-ppt-html/references/icons.md +176 -397
  41. package/top-ppt-html/references/infographics-stats.md +308 -308
  42. package/top-ppt-html/references/infographics-structure.md +226 -226
  43. package/top-ppt-html/references/infographics.md +43 -43
  44. package/top-ppt-html/references/layout-grammar.md +325 -315
  45. package/top-ppt-html/references/layouts-architecture.md +108 -108
  46. package/top-ppt-html/references/layouts-combo.md +600 -600
  47. package/top-ppt-html/references/layouts-research.md +160 -160
  48. package/top-ppt-html/references/modes.md +259 -254
  49. package/top-ppt-html/references/outline-design.md +275 -275
  50. package/top-ppt-html/references/playbook.md +284 -266
  51. package/top-ppt-html/references/pptx-export.md +227 -209
  52. package/top-ppt-html/references/presentation-craft.md +69 -0
  53. package/top-ppt-html/references/styles.md +370 -370
  54. package/top-ppt-html/references/tech-design.md +138 -138
  55. package/top-ppt-html/scripts/audit_css.py +109 -109
  56. package/top-ppt-html/scripts/audit_docs.py +176 -176
  57. package/top-ppt-html/scripts/audit_skill.py +231 -220
  58. package/top-ppt-html/scripts/audit_styles.py +351 -351
  59. package/top-ppt-html/scripts/build_examples.py +55 -2276
  60. package/top-ppt-html/scripts/build_pptx.js +2386 -2380
  61. package/top-ppt-html/scripts/capture_theme_overview.js +79 -78
  62. package/top-ppt-html/scripts/checks_html.py +166 -127
  63. package/top-ppt-html/scripts/cross_verify.py +298 -294
  64. package/top-ppt-html/scripts/env_probe.py +158 -158
  65. package/top-ppt-html/scripts/extract_model.py +210 -210
  66. package/top-ppt-html/scripts/extract_snippet.py +374 -374
  67. package/top-ppt-html/scripts/gen_channel_a.js +214 -214
  68. package/top-ppt-html/scripts/layout-constants.json +111 -18
  69. package/top-ppt-html/scripts/lib_layout_regions.js +410 -412
  70. package/top-ppt-html/scripts/measure_height.py +178 -178
  71. package/top-ppt-html/scripts/negative_tests.py +433 -307
  72. package/top-ppt-html/scripts/package_skill.py +294 -291
  73. package/top-ppt-html/scripts/prepare_images.py +341 -341
  74. package/top-ppt-html/scripts/probe_image_export.py +187 -188
  75. package/top-ppt-html/scripts/quality_gate.py +309 -301
  76. package/top-ppt-html/scripts/recommend_layout.py +384 -0
  77. package/top-ppt-html/scripts/regression.py +308 -308
  78. package/top-ppt-html/scripts/render_compare.py +275 -275
  79. package/top-ppt-html/scripts/render_from_model.py +698 -698
  80. package/top-ppt-html/scripts/scaffold_report.py +1054 -1054
  81. package/top-ppt-html/scripts/section-file-map.json +104 -104
  82. package/top-ppt-html/scripts/smoke_pptx.sh +30 -0
  83. package/top-ppt-html/scripts/sync_runtime.py +659 -662
  84. package/top-ppt-html/scripts/validate_pptx.py +1579 -1510
  85. package/top-ppt-html/scripts/validate_report.py +1755 -1456
  86. package/top-ppt-html/assets/examples/2026-09-09-architecture-spectrum.html +0 -3926
  87. package/top-ppt-html/assets/examples/2026-09-09-architecture-spectrum.model.json +0 -168
  88. package/top-ppt-html/assets/examples/2026-09-09-presentation-apple-mono.html +0 -4325
  89. package/top-ppt-html/assets/examples/2026-09-09-presentation-apple-mono.model.json +0 -321
  90. package/top-ppt-html/assets/examples/2026-09-09-presentation-brand-red.html +0 -4325
  91. package/top-ppt-html/assets/examples/2026-09-09-presentation-brand-red.model.json +0 -321
  92. package/top-ppt-html/assets/examples/2026-09-09-research-deep-teal.html +0 -5527
  93. package/top-ppt-html/assets/examples/2026-09-09-research-deep-teal.model.json +0 -914
  94. package/top-ppt-html/assets/examples/2026-09-09-research-indigo-violet.html +0 -5527
  95. package/top-ppt-html/assets/examples/2026-09-09-research-indigo-violet.model.json +0 -914
  96. package/top-ppt-html/assets/examples/2026-09-09-research-warm-sand.html +0 -5527
  97. package/top-ppt-html/assets/examples/2026-09-09-research-warm-sand.model.json +0 -914
  98. package/top-ppt-html/assets/theme-overview-presentation.png +0 -0
  99. package/top-ppt-html/references/design-system-engine.md +0 -235
  100. package/top-ppt-html/references/industry-benchmark.md +0 -105
  101. package/top-ppt-html/references/reform-plan.md +0 -252
  102. package/top-ppt-html/scripts/layout_slots.json +0 -830
@@ -1,78 +1,79 @@
1
- #!/usr/bin/env node
2
- /* TopPPT HTML · 主题参考图生成器(维护工具,非交付依赖、非交付硬门禁)
3
- *
4
- * 用法:
5
- * npm install playwright # 或在任意位置安装 playwright 后设置 NODE_PATH
6
- * npx playwright install chromium # 首次需要下载浏览器
7
- * node scripts/capture_theme_overview.js [--channel=msedge|chrome|chromium] [--scale=1.25]
8
- *
9
- * --scale = deviceScaleFactor(默认 1.25)。参考图只用于"让用户看见风格与主题",
10
- * 1.25 在 1480px 视口下已足够清晰,同时把单张 PNG 控制在 ~1MB 内(便于在 IDE 里快速打开)。
11
- *
12
- * 浏览器通道自动探测:优先命令行 --channel=,其次环境变量 TOP_PPT_BROWSER_CHANNEL,
13
- * 最后回落到 Playwright 自带的 chromium(无本机浏览器依赖)。
14
- *
15
- * 产物(assets/,覆盖旧图):
16
- * theme-overview.png —— 默认(演示 Tab)整体参考图
17
- * theme-overview-presentation.png —— 演示模式 Tab
18
- * theme-overview-research.png —— 研究模式 Tab(咨询密排形态)
19
- * theme-overview-architecture.png —— 架构模式 Tab(图为王形态)
20
- *
21
- * 每次截图前断言当前 Tab 文案,防止 Tab 切换未生效导致产出雷同图。 */
22
- const path = require('path');
23
- let chromium;
24
- try {
25
- ({ chromium } = require('playwright'));
26
- } catch (e) {
27
- console.error('未找到 playwright。请先安装:npm install playwright && npx playwright install chromium');
28
- console.error('(本脚本仅用于刷新 assets/theme-overview*.png 参考图,非技能交付依赖。)');
29
- process.exit(2);
30
- }
31
-
32
- const TAB_TEXT = { presentation: '演示汇报', research: '研究报告', architecture: '信息架构图' };
33
- const channelArg = (process.argv.find((a) => a.startsWith('--channel=')) || '').split('=')[1];
34
- const scaleArg = parseFloat((process.argv.find((a) => a.startsWith('--scale=')) || '').split('=')[1]);
35
- const SCALE = Number.isFinite(scaleArg) && scaleArg > 0 ? scaleArg : 1.25;
36
- const CHANNEL = channelArg || process.env.TOP_PPT_BROWSER_CHANNEL
37
- || process.env.TOP_PPT_BROWSER_CHANNEL || '';
38
-
39
- (async () => {
40
- const gallery = 'file:///' + path.resolve(__dirname, '../assets/style-gallery.html').replace(/\\/g, '/');
41
- const outDir = path.resolve(__dirname, '../assets');
42
- /* channel 为空 → 用 Playwright 自带 chromium(不依赖本机已装浏览器) */
43
- const browser = await chromium.launch(CHANNEL ? { channel: CHANNEL } : {});
44
- const page = await browser.newPage({ viewport: { width: 1480, height: 1200 }, deviceScaleFactor: SCALE });
45
- await page.goto(gallery);
46
- await page.waitForSelector('.preset');
47
-
48
- const modes = ['presentation', 'research', 'architecture'];
49
- for (const m of modes) {
50
- const idx = modes.indexOf(m);
51
- await page.evaluate((i) => document.querySelectorAll('.mtab')[i].click(), idx);
52
- await page.waitForTimeout(500);
53
- const on = await page.evaluate(() =>
54
- (document.querySelector('.mtab.on .mtab__t') || {}).textContent || '');
55
- if (on !== TAB_TEXT[m]) {
56
- throw new Error(`Tab 切换断言失败:期望「${TAB_TEXT[m]}」实际「${on}」(防止雷同图回归)`);
57
- }
58
- /* 卡片数必须等于单源风格数(PRESETS 由 sync_runtime.py 从 layout-constants.json 注入),
59
- * 防止「扩了风格但画廊/参考图没跟上」的静默漂移(旧断言只查 n < 9,太弱)。 */
60
- const n = await page.evaluate(() => document.querySelectorAll('.preset').length);
61
- const expected = await page.evaluate(() =>
62
- (typeof PRESETS === 'object' && PRESETS) ? Object.keys(PRESETS).length : 0);
63
- if (expected && n !== expected) {
64
- throw new Error(`画廊卡片数 ${n} ≠ 单源风格数 ${expected}(画廊与 layout-constants.json 漂移)`);
65
- }
66
- if (n < 9) throw new Error(`卡片数异常:${n}`);
67
- await page.screenshot({ path: path.join(outDir, `theme-overview-${m}.png`), fullPage: true });
68
- console.log(`captured: theme-overview-${m}.png (tab=${on}, cards=${n})`);
69
- }
70
-
71
- /* 主图回到演示 Tab(默认态) */
72
- await page.evaluate(() => document.querySelectorAll('.mtab')[0].click());
73
- await page.waitForTimeout(500);
74
- await page.screenshot({ path: path.join(outDir, 'theme-overview.png'), fullPage: true });
75
- console.log('captured: theme-overview.png (default presentation tab)');
76
- await browser.close();
77
- console.log(`done: 4 张主题参考图已刷新(deviceScaleFactor=${SCALE})`);
78
- })().catch((e) => { console.error(e); process.exit(1); });
1
+ #!/usr/bin/env node
2
+ /* TopPPT HTML · 主题参考图生成器(维护工具,非交付依赖、非交付硬门禁)
3
+ *
4
+ * 用法:
5
+ * npm install playwright # 或在任意位置安装 playwright 后设置 NODE_PATH
6
+ * npx playwright install chromium # 首次需要下载浏览器
7
+ * node scripts/capture_theme_overview.js [--channel=msedge|chrome|chromium] [--scale=1.25]
8
+ *
9
+ * --scale = deviceScaleFactor(默认 1.25)。参考图只用于"让用户看见风格与主题",
10
+ * 1.25 在 1480px 视口下已足够清晰,同时把单张 PNG 控制在 ~1MB 内(便于在 IDE 里快速打开)。
11
+ *
12
+ * 浏览器通道自动探测:优先命令行 --channel=,其次环境变量 TOP_PPT_BROWSER_CHANNEL,
13
+ * 最后回落到 Playwright 自带的 chromium(无本机浏览器依赖)。
14
+ *
15
+ * 产物(assets/,覆盖旧图):
16
+ * theme-overview.png —— 默认(演示 Tab)整体参考图(= 演示态)
17
+ * theme-overview-research.png —— 研究模式 Tab(咨询密排形态)
18
+ * theme-overview-architecture.png —— 架构模式 Tab(图为王形态)
19
+ *
20
+ * 演示态与整体图内容相同,不再单独写 theme-overview-presentation.png。
21
+ * 每次截图前断言当前 Tab 文案,防止 Tab 切换未生效导致产出雷同图。 */
22
+ const path = require('path');
23
+ let chromium;
24
+ try {
25
+ ({ chromium } = require('playwright'));
26
+ } catch (e) {
27
+ console.error('未找到 playwright。请先安装:npm install playwright && npx playwright install chromium');
28
+ console.error('(本脚本仅用于刷新 assets/theme-overview*.png 参考图,非技能交付依赖。)');
29
+ process.exit(2);
30
+ }
31
+
32
+ const TAB_TEXT = { presentation: '演示汇报', research: '研究报告', architecture: '信息架构图' };
33
+ const channelArg = (process.argv.find((a) => a.startsWith('--channel=')) || '').split('=')[1];
34
+ const scaleArg = parseFloat((process.argv.find((a) => a.startsWith('--scale=')) || '').split('=')[1]);
35
+ const SCALE = Number.isFinite(scaleArg) && scaleArg > 0 ? scaleArg : 1.25;
36
+ const CHANNEL = channelArg || process.env.TOP_PPT_BROWSER_CHANNEL
37
+ || process.env.TOP_PPT_BROWSER_CHANNEL || '';
38
+
39
+ (async () => {
40
+ const gallery = 'file:///' + path.resolve(__dirname, '../assets/style-gallery.html').replace(/\\/g, '/');
41
+ const outDir = path.resolve(__dirname, '../assets');
42
+ /* channel 为空 → 用 Playwright 自带 chromium(不依赖本机已装浏览器) */
43
+ const browser = await chromium.launch(CHANNEL ? { channel: CHANNEL } : {});
44
+ const page = await browser.newPage({ viewport: { width: 1480, height: 1200 }, deviceScaleFactor: SCALE });
45
+ await page.goto(gallery);
46
+ await page.waitForSelector('.preset');
47
+
48
+ const modes = ['presentation', 'research', 'architecture'];
49
+ for (const m of modes) {
50
+ const idx = modes.indexOf(m);
51
+ await page.evaluate((i) => document.querySelectorAll('.mtab')[i].click(), idx);
52
+ await page.waitForTimeout(500);
53
+ const on = await page.evaluate(() =>
54
+ (document.querySelector('.mtab.on .mtab__t') || {}).textContent || '');
55
+ if (on !== TAB_TEXT[m]) {
56
+ throw new Error(`Tab 切换断言失败:期望「${TAB_TEXT[m]}」实际「${on}」(防止雷同图回归)`);
57
+ }
58
+ /* 卡片数必须等于单源风格数(PRESETS 由 sync_runtime.py 从 layout-constants.json 注入),
59
+ * 防止「扩了风格但画廊/参考图没跟上」的静默漂移(旧断言只查 n < 9,太弱)。 */
60
+ const n = await page.evaluate(() => document.querySelectorAll('.preset').length);
61
+ const expected = await page.evaluate(() =>
62
+ (typeof PRESETS === 'object' && PRESETS) ? Object.keys(PRESETS).length : 0);
63
+ if (expected && n !== expected) {
64
+ throw new Error(`画廊卡片数 ${n} ≠ 单源风格数 ${expected}(画廊与 layout-constants.json 漂移)`);
65
+ }
66
+ if (n < 9) throw new Error(`卡片数异常:${n}`);
67
+ // presentation tab → theme-overview.png only (no duplicate *-presentation.png)
68
+ if (m === 'presentation') {
69
+ await page.screenshot({ path: path.join(outDir, 'theme-overview.png'), fullPage: true });
70
+ console.log(`captured: theme-overview.png (tab=${on}, cards=${n}; presentation = overview)`);
71
+ } else {
72
+ await page.screenshot({ path: path.join(outDir, `theme-overview-${m}.png`), fullPage: true });
73
+ console.log(`captured: theme-overview-${m}.png (tab=${on}, cards=${n})`);
74
+ }
75
+ }
76
+
77
+ await browser.close();
78
+ console.log(`done: 3 张主题参考图已刷新(deviceScaleFactor=${SCALE})`);
79
+ })().catch((e) => { console.error(e); process.exit(1); });
@@ -1,127 +1,166 @@
1
- #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
- """TopPPT HTML · HTML 检查公共库(单源:承载正则 / 图表通道 / 多样性下限 / 组合版式)
4
-
5
- 消费方:validate_report.py · quality_gate.py · evals/run_evals.py
6
- 禁止在消费方再手抄 CARRIERS / 多样性公式 / 组合版式判定。
7
- 阈值仍读 layout-constants.json(charts.variety / charts.registry)。
8
- """
9
- from __future__ import annotations
10
-
11
- import json
12
- import re
13
- from pathlib import Path
14
-
15
- ROOT = Path(__file__).resolve().parent.parent
16
- _LC_PATH = ROOT / 'scripts' / 'layout-constants.json'
17
-
18
- # 承载类型正则(组合版式:一页是否含 ≥2 种承载)
19
- CARRIERS = [
20
- r'<svg\b[^>]*data-chart=',
21
- r'class="tbl-wrap',
22
- r'class="card[\s"]',
23
- r'class="metric[\s"]',
24
- r'class="media[\s"]',
25
- r'class="arch[\s"]',
26
- r'class="lane[\s"]',
27
- r'class="matrix[\s"]',
28
- r'class="heat[\s"]',
29
- r'class="bul[\s"]',
30
- r'class="pyr[\s"]',
31
- r'class="steps[\s"]',
32
- r'class="exhibit[\s"]',
33
- r'class="cols-2',
34
- r'class="cols-3',
35
- ]
36
-
37
- SKIP_BAND_IDS = ('agenda', 'cover', 'refs', 'appendix', 'next')
38
-
39
-
40
- def load_lc() -> dict:
41
- return json.loads(_LC_PATH.read_text(encoding='utf-8'))
42
-
43
-
44
- def chart_registry(lc: dict | None = None) -> dict:
45
- lc = lc or load_lc()
46
- return {k: v for k, v in ((lc.get('charts') or {}).get('registry') or {}).items()
47
- if isinstance(v, dict)}
48
-
49
-
50
- def chart_variety(lc: dict | None = None) -> dict:
51
- lc = lc or load_lc()
52
- return (lc.get('charts') or {}).get('variety') or {}
53
-
54
-
55
- def bands(html: str) -> list[str]:
56
- return re.split(r'(?=<section class="band)', html)[1:]
57
-
58
-
59
- def content_bands(html: str, skip_ids: tuple[str, ...] = SKIP_BAND_IDS) -> list[str]:
60
- out = []
61
- for b in bands(html):
62
- head = b[:240]
63
- if any(f'id="{i}"' in head for i in skip_ids):
64
- continue
65
- out.append(b)
66
- return out
67
-
68
-
69
- def chart_types_per_page(html: str) -> list[list[str]]:
70
- return [re.findall(r'data-chart="([^"]+)"', b) for b in bands(html)]
71
-
72
-
73
- def distinct_chart_types(html: str) -> list[str]:
74
- used = [t for ts in chart_types_per_page(html) for t in ts]
75
- return sorted(set(used))
76
-
77
-
78
- def variety_floor(mode: str, n_chart_pages: int, variety: dict | None = None) -> int:
79
- """类型数下限 = min(模式上限, max(1, ⌈图表页数 × ratio⌉))"""
80
- v = variety if variety is not None else chart_variety()
81
- floor = int((v.get('minTypes') or {}).get(mode, 4) or 4)
82
- ratio = float(v.get('minTypesRatio') or 0.6)
83
- return min(floor, max(1, int(-(-n_chart_pages * ratio // 1))))
84
-
85
-
86
- def adjacent_same_type(pages: list[list[str]]) -> list[str]:
87
- """相邻图表页同型告警文案列表。"""
88
- adj, prev, prev_i = [], None, 0
89
- for i, ts in enumerate(pages):
90
- if not ts:
91
- continue
92
- if prev and set(ts) & set(prev):
93
- adj.append(f'第 {prev_i + 1}/{i + 1} 页同型 {sorted(set(ts) & set(prev))}')
94
- prev, prev_i = ts, i
95
- return adj
96
-
97
-
98
- def carrier_count(band: str) -> int:
99
- return sum(1 for pat in CARRIERS if re.search(pat, band))
100
-
101
-
102
- def composite_pages(html: str) -> tuple[int, int]:
103
- """返回 (含 ≥2 种承载的内容页数, 内容页总数)。"""
104
- content = content_bands(html)
105
- multi = sum(1 for b in content if carrier_count(b) >= 2)
106
- return multi, len(content)
107
-
108
-
109
- def composite_required(mode: str, variety: dict | None = None) -> float:
110
- """组合版式比例下限;非 compositeModes 返回 0。"""
111
- v = variety if variety is not None else chart_variety()
112
- if mode not in (v.get('compositeModes') or ['research']):
113
- return 0.0
114
- return float(v.get('compositeMinRatio') or 0)
115
-
116
-
117
- def chart_channel(chart_type: str, reg: dict | None = None) -> str:
118
- reg = reg if reg is not None else chart_registry()
119
- spec = reg.get(str(chart_type or 'bar').lower()) or {}
120
- return str(spec.get('pptx') or 'native')
121
-
122
-
123
- def chart_datatable_mode(chart: dict, reg: dict | None = None) -> str:
124
- reg = reg if reg is not None else chart_registry()
125
- spec = reg.get(str(chart.get('type') or 'bar').lower()) or {}
126
- mode = str(chart.get('dataTable') or spec.get('dataTable') or 'notes').lower()
127
- return 'inline' if mode == 'appendix' else mode
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """TopPPT HTML · HTML 检查公共库(单源:承载正则 / 图表通道 / 多样性下限 / 组合版式)
4
+
5
+ 消费方:validate_report.py · quality_gate.py · evals/run_evals.py
6
+ 禁止在消费方再手抄 CARRIERS / 多样性公式 / 组合版式判定。
7
+ 阈值仍读 layout-constants.json(charts.variety / charts.registry)。
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import re
13
+ from pathlib import Path
14
+
15
+ ROOT = Path(__file__).resolve().parent.parent
16
+ _LC_PATH = ROOT / 'scripts' / 'layout-constants.json'
17
+
18
+ # 承载类型正则(组合版式:一页是否含 ≥2 种承载)
19
+ CARRIERS = [
20
+ r'<svg\b[^>]*data-chart=',
21
+ r'class="tbl-wrap',
22
+ r'class="card[\s"]',
23
+ r'class="metric[\s"]',
24
+ r'class="media[\s"]',
25
+ r'class="arch[\s"]',
26
+ r'class="lane[\s"]',
27
+ r'class="matrix[\s"]',
28
+ r'class="heat[\s"]',
29
+ r'class="bul[\s"]',
30
+ r'class="pyr[\s"]',
31
+ r'class="steps[\s"]',
32
+ r'class="exhibit[\s"]',
33
+ r'class="cols-2',
34
+ r'class="cols-3',
35
+ ]
36
+
37
+ SKIP_BAND_IDS = ('agenda', 'cover', 'refs', 'appendix', 'next')
38
+
39
+
40
+ def load_lc() -> dict:
41
+ return json.loads(_LC_PATH.read_text(encoding='utf-8'))
42
+
43
+
44
+ def chart_registry(lc: dict | None = None) -> dict:
45
+ lc = lc or load_lc()
46
+ return {k: v for k, v in ((lc.get('charts') or {}).get('registry') or {}).items()
47
+ if isinstance(v, dict)}
48
+
49
+
50
+ def chart_variety(lc: dict | None = None) -> dict:
51
+ lc = lc or load_lc()
52
+ return (lc.get('charts') or {}).get('variety') or {}
53
+
54
+
55
+ def bands(html: str) -> list[str]:
56
+ return re.split(r'(?=<section class="band)', html)[1:]
57
+
58
+
59
+ def content_bands(html: str, skip_ids: tuple[str, ...] = SKIP_BAND_IDS) -> list[str]:
60
+ out = []
61
+ for b in bands(html):
62
+ head = b[:240]
63
+ if any(f'id="{i}"' in head for i in skip_ids):
64
+ continue
65
+ out.append(b)
66
+ return out
67
+
68
+
69
+ def chart_types_per_page(html: str) -> list[list[str]]:
70
+ return [re.findall(r'data-chart="([^"]+)"', b) for b in bands(html)]
71
+
72
+
73
+ def distinct_chart_types(html: str) -> list[str]:
74
+ used = [t for ts in chart_types_per_page(html) for t in ts]
75
+ return sorted(set(used))
76
+
77
+
78
+ def variety_floor(mode: str, n_chart_pages: int, variety: dict | None = None) -> int:
79
+ """类型数下限 = min(模式上限, max(1, ⌈图表页数 × ratio⌉))"""
80
+ v = variety if variety is not None else chart_variety()
81
+ floor = int((v.get('minTypes') or {}).get(mode, 4) or 4)
82
+ ratio = float(v.get('minTypesRatio') or 0.6)
83
+ return min(floor, max(1, int(-(-n_chart_pages * ratio // 1))))
84
+
85
+
86
+
87
+ def core_charts(lc: dict | None = None) -> list[str]:
88
+ lc = lc or load_lc()
89
+ ls = lc.get('layoutSystem') or {}
90
+ return list(ls.get('defaultCharts') or
91
+ ['bar', 'hbar', 'line', 'donut', 'progress', 'area', 'stack', 'dualline'])
92
+
93
+
94
+ def advanced_charts(lc: dict | None = None) -> set[str]:
95
+ lc = lc or load_lc()
96
+ ls = lc.get('layoutSystem') or {}
97
+ return set(ls.get('advancedCharts') or [])
98
+
99
+
100
+ def variety_core_preference(used: list[str], variety: dict | None = None,
101
+ lc: dict | None = None) -> str | None:
102
+ """preferCoreFirst:advanced 已用但核图多样性不足 → WARN 文案。
103
+
104
+ advanced 仍计入 minTypes(不罚);仅提示勿用冷门图凑下限。
105
+ """
106
+ v = variety if variety is not None else chart_variety(lc)
107
+ if not v.get('preferCoreFirst'):
108
+ return None
109
+ core = set(core_charts(lc))
110
+ adv = advanced_charts(lc)
111
+ distinct = set(used)
112
+ core_used = distinct & core
113
+ adv_used = distinct & adv if adv else (distinct - core)
114
+ if not adv_used:
115
+ return None
116
+ floor_hint = int(v.get('corePreferMin') or 3)
117
+ if len(core_used) >= min(floor_hint, len(core)):
118
+ return None
119
+ return (
120
+ f"核图仅 {sorted(core_used) or '∅'}(建议先 ≥{floor_hint} 种核图拉开多样性);"
121
+ f"已用 advanced {sorted(adv_used)}——允许计入 minTypes,但勿为凑下限而选用;"
122
+ f"仅内容意图命中时用 advanced"
123
+ )
124
+
125
+ def adjacent_same_type(pages: list[list[str]]) -> list[str]:
126
+ """相邻图表页同型告警文案列表。"""
127
+ adj, prev, prev_i = [], None, 0
128
+ for i, ts in enumerate(pages):
129
+ if not ts:
130
+ continue
131
+ if prev and set(ts) & set(prev):
132
+ adj.append(f'第 {prev_i + 1}/{i + 1} 页同型 {sorted(set(ts) & set(prev))}')
133
+ prev, prev_i = ts, i
134
+ return adj
135
+
136
+
137
+ def carrier_count(band: str) -> int:
138
+ return sum(1 for pat in CARRIERS if re.search(pat, band))
139
+
140
+
141
+ def composite_pages(html: str) -> tuple[int, int]:
142
+ """返回 (含 ≥2 种承载的内容页数, 内容页总数)。"""
143
+ content = content_bands(html)
144
+ multi = sum(1 for b in content if carrier_count(b) >= 2)
145
+ return multi, len(content)
146
+
147
+
148
+ def composite_required(mode: str, variety: dict | None = None) -> float:
149
+ """组合版式比例下限;非 compositeModes 返回 0。"""
150
+ v = variety if variety is not None else chart_variety()
151
+ if mode not in (v.get('compositeModes') or ['research']):
152
+ return 0.0
153
+ return float(v.get('compositeMinRatio') or 0)
154
+
155
+
156
+ def chart_channel(chart_type: str, reg: dict | None = None) -> str:
157
+ reg = reg if reg is not None else chart_registry()
158
+ spec = reg.get(str(chart_type or 'bar').lower()) or {}
159
+ return str(spec.get('pptx') or 'native')
160
+
161
+
162
+ def chart_datatable_mode(chart: dict, reg: dict | None = None) -> str:
163
+ reg = reg if reg is not None else chart_registry()
164
+ spec = reg.get(str(chart.get('type') or 'bar').lower()) or {}
165
+ mode = str(chart.get('dataTable') or spec.get('dataTable') or 'notes').lower()
166
+ return 'inline' if mode == 'appendix' else mode