@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,275 +1,275 @@
1
- #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
- """TopPPT HTML · 深度模式渲染对照(可选工具,缺失依赖自动跳过)
4
-
5
- 按需深度模式的「渲染对照闭环」:把交付 PPTX 渲染成可逐页对照的产物,并生成
6
- 一张并排对照页与一份**自动可算的偏差登记**(deviations.json)。
7
-
8
- 设计取舍(为什么不做成硬门禁):
9
- · 渲染依赖外部程序(LibreOffice / PowerPoint COM / poppler),不是每台机器都有;
10
- 本脚本在缺失依赖时**跳过并打印安装指引**,绝不阻断交付。
11
- · 视觉语义的最终判定仍需人眼——脚本负责把"对照材料"准备好并把可量化偏差算出来,
12
- 而不是假装能自动判断"好不好看"。
13
-
14
- 用法:
15
- python scripts/render_compare.py <报告.pptx> [--html <报告.html>] [--out <输出目录>] [--dpi 96]
16
-
17
- 产物(默认输出到 <pptx 同目录>/render-compare/):
18
- deck.pdf PPTX 渲染的 PDF(soffice --convert-to pdf)
19
- page-01.png … PDF 逐页位图(需 poppler/pdftoppm、mutool 或 ImageMagick 之一)
20
- compare.html 左 HTML / 右 PPTX 的并排对照页(无位图时右栏退化为 PDF 内嵌)
21
- deviations.json 自动可算的偏差登记(页数/主题/图表通道/数据表/图片/锚点)
22
- """
23
- from __future__ import annotations
24
-
25
- import argparse
26
- import json
27
- import re
28
- import shutil
29
- import subprocess
30
- import sys
31
- from pathlib import Path
32
- from xml.etree import ElementTree as ET
33
-
34
- try:
35
- sys.stdout.reconfigure(encoding='utf-8', errors='replace')
36
- sys.stderr.reconfigure(encoding='utf-8', errors='replace')
37
- except Exception:
38
- pass
39
-
40
- ROOT = Path(__file__).resolve().parent.parent
41
- LC_PATH = ROOT / 'scripts' / 'layout-constants.json'
42
- NS = {
43
- 'a': 'http://schemas.openxmlformats.org/drawingml/2006/main',
44
- 'p': 'http://schemas.openxmlformats.org/presentationml/2006/main',
45
- 'c': 'http://schemas.openxmlformats.org/drawingml/2006/chart',
46
- }
47
-
48
-
49
- def load_constants() -> dict:
50
- try:
51
- return json.loads(LC_PATH.read_text(encoding='utf-8'))
52
- except (OSError, json.JSONDecodeError):
53
- return {}
54
-
55
-
56
- def find_soffice() -> str | None:
57
- import os
58
- for var in ('SOFFICE', 'LIBREOFFICE', 'TOP_PPT_SOFFICE'):
59
- val = os.environ.get(var)
60
- if val and Path(val).exists():
61
- return val
62
- for name in ('soffice', 'soffice.exe', 'libreoffice'):
63
- found = shutil.which(name)
64
- if found:
65
- return found
66
- for cand in (r'C:\Program Files\LibreOffice\program\soffice.exe',
67
- r'C:\Program Files (x86)\LibreOffice\program\soffice.exe',
68
- '/Applications/LibreOffice.app/Contents/MacOS/soffice'):
69
- if Path(cand).exists():
70
- return cand
71
- return None
72
-
73
-
74
- def find_pdf_rasterizer() -> tuple[str, str] | None:
75
- """返回 (可执行文件, 类型):pdftoppm / mutool / magick。"""
76
- for name, kind in (('pdftoppm', 'pdftoppm'), ('mutool', 'mutool'),
77
- ('magick', 'magick'), ('convert', 'magick')):
78
- found = shutil.which(name)
79
- if found:
80
- return found, kind
81
- return None
82
-
83
-
84
- def pptx_metrics(pptx: Path) -> dict:
85
- """从 PPTX 包内直接读结构指标(零依赖,与 validate_pptx 同口径的轻量版)。"""
86
- import zipfile
87
- out = {'slide_count': 0, 'charts': 0, 'tables': 0, 'pictures': 0, 'themes': []}
88
- try:
89
- with zipfile.ZipFile(pptx) as zf:
90
- names = [n for n in zf.namelist() if re.match(r'ppt/slides/slide\d+\.xml$', n)]
91
- out['slide_count'] = len(names)
92
- for name in sorted(names, key=lambda n: int(re.search(r'(\d+)', n.split('/')[-1]).group(1))):
93
- root = ET.fromstring(zf.read(name))
94
- out['charts'] += len(root.findall('.//c:chart', NS))
95
- out['tables'] += len(root.findall('.//a:tbl', NS))
96
- out['pictures'] += len(root.findall('.//p:pic', NS))
97
- except (OSError, zipfile.BadZipFile, ET.ParseError, AttributeError):
98
- pass
99
- return out
100
-
101
-
102
- def html_metrics(html: Path) -> dict:
103
- """HTML 侧结构指标:band 数 / data-theme / data-mode / 图表标记数。"""
104
- try:
105
- txt = html.read_text(encoding='utf-8')
106
- except OSError:
107
- return {}
108
- theme = re.search(r'<html[^>]*data-theme="([^"]+)"', txt)
109
- mode = re.search(r'<html[^>]*data-mode="([^"]+)"', txt)
110
- model = re.search(r'window\.REPORT_MODEL\s*=\s*(\{[\s\S]*?\})\s*;', txt)
111
- sections = 0
112
- if model:
113
- try:
114
- sections = len((json.loads(model.group(1)).get('sections') or []))
115
- except json.JSONDecodeError:
116
- sections = 0
117
- return {
118
- 'bands': len(re.findall(r'<section[^>]*class="[^"]*\bband\b', txt)),
119
- 'theme': theme.group(1) if theme else None,
120
- 'mode': mode.group(1) if mode else None,
121
- 'sections': sections,
122
- 'charts': len(re.findall(r'data-chart="', txt)),
123
- }
124
-
125
-
126
- def deviations(pptx_m: dict, html_m: dict, lc: dict) -> list[dict]:
127
- """自动可算的偏差登记(人眼判定之外的可量化部分)。"""
128
- devs: list[dict] = []
129
- if not html_m:
130
- return devs
131
- bands, sections = html_m.get('bands', 0), html_m.get('sections', 0)
132
- if bands and sections:
133
- expected_lo, expected_hi = sections + 3, sections + 7
134
- if not (expected_lo <= bands <= expected_hi):
135
- devs.append({'kind': 'page-count', 'severity': 'medium',
136
- 'detail': f'HTML band {bands} 不在模型 {sections} 章的预期区间 '
137
- f'[{expected_lo},{expected_hi}](与 validate_report 同口径)'})
138
- # 允许 PPTX 与 HTML 相差 1 页(architecture 可省 Agenda;声明式图片不增页)
139
- if pptx_m.get('slide_count') and bands and abs(pptx_m['slide_count'] - bands) > 1:
140
- devs.append({'kind': 'page-count-mismatch', 'severity': 'medium',
141
- 'detail': f'PPTX {pptx_m["slide_count"]} 页 vs HTML {bands} 页(差 >1,需逐页核对)'})
142
- n_charts = html_m.get('charts', 0)
143
- if n_charts and pptx_m.get('charts', 0) == 0:
144
- devs.append({'kind': 'chart-channel', 'severity': 'high',
145
- 'detail': f'HTML 有 {n_charts} 个图表标记,但 PPTX 无 chart part——'
146
- '若为形状通道图表属预期(需附数据表),否则应走原生通道'})
147
- return devs
148
-
149
-
150
- def write_compare_html(out: Path, pptx: Path, html: Path | None,
151
- pdf: Path | None, pngs: list[Path], devs: list[dict],
152
- pptx_m: dict, html_m: dict) -> Path:
153
- def rows() -> str:
154
- if not devs:
155
- return '<tr><td colspan="3">自动可算项未发现偏差(视觉语义仍需人眼逐页对照)</td></tr>'
156
- return ''.join(
157
- f'<tr><td>{d["kind"]}</td><td>{d["severity"]}</td><td>{d["detail"]}</td></tr>'
158
- for d in devs)
159
-
160
- def png_gallery() -> str:
161
- if not pngs:
162
- return '<p class="note">未找到 PDF 位图工具(pdftoppm / mutool / magick)——右栏退化为 PDF 内嵌。</p>'
163
- return ''.join(f'<figure><img src="{p.name}" alt="page"><figcaption>{p.stem}</figcaption></figure>'
164
- for p in pngs)
165
-
166
- left = (f'<iframe src="{html.resolve().as_uri()}" title="HTML 报告"></iframe>' if html
167
- else '<p class="note">未提供 --html,无法并排对照 HTML 侧。</p>')
168
- right = (f'<embed src="{pdf.name}" type="application/pdf" />' if pdf
169
- else '<p class="note">未渲染 PDF(缺 LibreOffice)。</p>')
170
- doc = f'''<!doctype html><html lang="zh-CN"><head><meta charset="utf-8">
171
- <title>渲染对照 · {pptx.name}</title>
172
- <style>
173
- :root{{--bd:#dadce0;--tx:#202124;--bg:#fff;--sf:#f8f9fa}}
174
- *{{box-sizing:border-box}}body{{margin:0;font:14px/1.6 system-ui,"Microsoft YaHei",sans-serif;color:var(--tx);background:var(--bg)}}
175
- header{{padding:16px 24px;border-bottom:1px solid var(--bd)}}
176
- h1{{margin:0 0 4px;font-size:18px}}
177
- .meta{{color:#5f6368;font-size:13px}}
178
- .grid{{display:grid;grid-template-columns:1fr 1fr;gap:1px;background:var(--bd);height:70vh}}
179
- .grid>div{{background:var(--bg);overflow:auto}}
180
- iframe,embed{{width:100%;height:100%;border:0;display:block}}
181
- table{{border-collapse:collapse;width:100%;margin:16px 0}}
182
- th,td{{border:1px solid var(--bd);padding:8px 10px;text-align:left;vertical-align:top;font-size:13px}}
183
- th{{background:var(--sf)}}
184
- .gallery{{display:flex;flex-wrap:wrap;gap:12px;padding:0 24px 24px}}
185
- figure{{margin:0;width:320px}}img{{width:100%;border:1px solid var(--bd);border-radius:6px}}
186
- figcaption{{font-size:12px;color:#5f6368;padding-top:4px}}
187
- .note{{padding:16px 24px;color:#5f6368}}
188
- section{{padding:0 24px 8px}}
189
- </style></head><body>
190
- <header><h1>渲染对照 · {pptx.name}</h1>
191
- <div class="meta">PPTX {pptx_m.get('slide_count', 0)} 页 · chart part {pptx_m.get('charts', 0)} · 表格 {pptx_m.get('tables', 0)} · 图片 {pptx_m.get('pictures', 0)}
192
- {' · HTML ' + str(html_m.get('bands', 0)) + ' 页(' + str(html_m.get('mode') or '?') + '/' + str(html_m.get('theme') or '?') + ')' if html_m else ''}</div></header>
193
- <section><h2 style="font-size:15px;margin:16px 0 0">自动可算偏差登记</h2></section>
194
- <table><thead><tr><th>类型</th><th>严重度</th><th>说明</th></tr></thead><tbody>{rows()}</tbody></table>
195
- <div class="grid"><div>{left}</div><div>{right}</div></div>
196
- <section><h2 style="font-size:15px">PPTX 逐页位图</h2></section>
197
- <div class="gallery">{png_gallery()}</div>
198
- </body></html>'''
199
- target = out / 'compare.html'
200
- target.write_text(doc, encoding='utf-8')
201
- return target
202
-
203
-
204
- def main(argv: list[str] | None = None) -> int:
205
- parser = argparse.ArgumentParser(description='TopPPT HTML 深度模式渲染对照(可选工具)')
206
- parser.add_argument('pptx', help='交付 PPTX 路径')
207
- parser.add_argument('--html', help='对应 HTML 报告路径(可选,用于并排对照)')
208
- parser.add_argument('--out', help='输出目录(默认 <pptx 同目录>/render-compare)')
209
- parser.add_argument('--dpi', type=int, default=96, help='位图 DPI(默认 96)')
210
- args = parser.parse_args(argv)
211
-
212
- pptx = Path(args.pptx).resolve()
213
- if not pptx.exists():
214
- print(f'错误: 找不到 {pptx}')
215
- return 2
216
- out = Path(args.out).resolve() if args.out else pptx.parent / 'render-compare'
217
- out.mkdir(parents=True, exist_ok=True)
218
-
219
- lc = load_constants()
220
- pptx_m = pptx_metrics(pptx)
221
- html_path = Path(args.html).resolve() if args.html else None
222
- html_m = html_metrics(html_path) if html_path and html_path.exists() else {}
223
- devs = deviations(pptx_m, html_m, lc)
224
-
225
- pdf: Path | None = None
226
- pngs: list[Path] = []
227
- soffice = find_soffice()
228
- if soffice:
229
- print(f'LibreOffice: {soffice}')
230
- try:
231
- subprocess.run([soffice, '--headless', '--norestore', '--convert-to', 'pdf',
232
- '--outdir', str(out), str(pptx)],
233
- check=True, capture_output=True, timeout=300)
234
- cand = out / (pptx.stem + '.pdf')
235
- pdf = cand if cand.exists() else None
236
- print(f' 已渲染: {pdf.name}' if pdf else ' 渲染完成但未找到 PDF 产物')
237
- except (subprocess.SubprocessError, OSError) as exc:
238
- print(f' 渲染失败(跳过,不阻断交付): {exc}')
239
- else:
240
- print('未找到 LibreOffice(soffice)——跳过 PPTX 渲染。')
241
- print(' 安装后可重跑:Windows https://www.libreoffice.org/download/ ;')
242
- print(' 或用环境变量 SOFFICE 指定 soffice 可执行文件路径。')
243
-
244
- raster = find_pdf_rasterizer()
245
- if pdf and raster:
246
- exe, kind = raster
247
- try:
248
- if kind == 'pdftoppm':
249
- subprocess.run([exe, '-r', str(args.dpi), '-png', str(pdf), str(out / 'page')],
250
- check=True, capture_output=True, timeout=300)
251
- elif kind == 'mutool':
252
- subprocess.run([exe, 'draw', '-r', str(args.dpi), '-o', str(out / 'page-%02d.png'), str(pdf)],
253
- check=True, capture_output=True, timeout=300)
254
- else:
255
- subprocess.run([exe, '-density', str(args.dpi), str(pdf), str(out / 'page-%02d.png')],
256
- check=True, capture_output=True, timeout=300)
257
- pngs = sorted(out.glob('page*.png'))
258
- print(f' 逐页位图: {len(pngs)} 张')
259
- except (subprocess.SubprocessError, OSError) as exc:
260
- print(f' 位图化失败(跳过): {exc}')
261
- elif pdf:
262
- print('未找到 PDF 位图工具(pdftoppm / mutool / magick)——保留 PDF,对照页右栏内嵌 PDF。')
263
-
264
- compare = write_compare_html(out, pptx, html_path, pdf, pngs, devs, pptx_m, html_m)
265
- (out / 'deviations.json').write_text(
266
- json.dumps({'pptx': pptx_m, 'html': html_m, 'deviations': devs}, ensure_ascii=False, indent=2) + '\n',
267
- encoding='utf-8')
268
- print(f'对照页: {compare}')
269
- print(f'偏差登记: {out / "deviations.json"}({len(devs)} 项自动可算偏差)')
270
- print('提示:视觉语义(底色系统 / 图表形态 / 留白节奏)仍需人眼在对照页上逐页确认。')
271
- return 0
272
-
273
-
274
- if __name__ == '__main__':
275
- sys.exit(main())
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """TopPPT HTML · 深度模式渲染对照(可选工具,缺失依赖自动跳过)
4
+
5
+ 按需深度模式的「渲染对照闭环」:把交付 PPTX 渲染成可逐页对照的产物,并生成
6
+ 一张并排对照页与一份**自动可算的偏差登记**(deviations.json)。
7
+
8
+ 设计取舍(为什么不做成硬门禁):
9
+ · 渲染依赖外部程序(LibreOffice / PowerPoint COM / poppler),不是每台机器都有;
10
+ 本脚本在缺失依赖时**跳过并打印安装指引**,绝不阻断交付。
11
+ · 视觉语义的最终判定仍需人眼——脚本负责把"对照材料"准备好并把可量化偏差算出来,
12
+ 而不是假装能自动判断"好不好看"。
13
+
14
+ 用法:
15
+ python scripts/render_compare.py <报告.pptx> [--html <报告.html>] [--out <输出目录>] [--dpi 96]
16
+
17
+ 产物(默认输出到 <pptx 同目录>/render-compare/):
18
+ deck.pdf PPTX 渲染的 PDF(soffice --convert-to pdf)
19
+ page-01.png … PDF 逐页位图(需 poppler/pdftoppm、mutool 或 ImageMagick 之一)
20
+ compare.html 左 HTML / 右 PPTX 的并排对照页(无位图时右栏退化为 PDF 内嵌)
21
+ deviations.json 自动可算的偏差登记(页数/主题/图表通道/数据表/图片/锚点)
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import argparse
26
+ import json
27
+ import re
28
+ import shutil
29
+ import subprocess
30
+ import sys
31
+ from pathlib import Path
32
+ from xml.etree import ElementTree as ET
33
+
34
+ try:
35
+ sys.stdout.reconfigure(encoding='utf-8', errors='replace')
36
+ sys.stderr.reconfigure(encoding='utf-8', errors='replace')
37
+ except Exception:
38
+ pass
39
+
40
+ ROOT = Path(__file__).resolve().parent.parent
41
+ LC_PATH = ROOT / 'scripts' / 'layout-constants.json'
42
+ NS = {
43
+ 'a': 'http://schemas.openxmlformats.org/drawingml/2006/main',
44
+ 'p': 'http://schemas.openxmlformats.org/presentationml/2006/main',
45
+ 'c': 'http://schemas.openxmlformats.org/drawingml/2006/chart',
46
+ }
47
+
48
+
49
+ def load_constants() -> dict:
50
+ try:
51
+ return json.loads(LC_PATH.read_text(encoding='utf-8'))
52
+ except (OSError, json.JSONDecodeError):
53
+ return {}
54
+
55
+
56
+ def find_soffice() -> str | None:
57
+ import os
58
+ for var in ('SOFFICE', 'LIBREOFFICE', 'TOP_PPT_SOFFICE'):
59
+ val = os.environ.get(var)
60
+ if val and Path(val).exists():
61
+ return val
62
+ for name in ('soffice', 'soffice.exe', 'libreoffice'):
63
+ found = shutil.which(name)
64
+ if found:
65
+ return found
66
+ for cand in (r'C:\Program Files\LibreOffice\program\soffice.exe',
67
+ r'C:\Program Files (x86)\LibreOffice\program\soffice.exe',
68
+ '/Applications/LibreOffice.app/Contents/MacOS/soffice'):
69
+ if Path(cand).exists():
70
+ return cand
71
+ return None
72
+
73
+
74
+ def find_pdf_rasterizer() -> tuple[str, str] | None:
75
+ """返回 (可执行文件, 类型):pdftoppm / mutool / magick。"""
76
+ for name, kind in (('pdftoppm', 'pdftoppm'), ('mutool', 'mutool'),
77
+ ('magick', 'magick'), ('convert', 'magick')):
78
+ found = shutil.which(name)
79
+ if found:
80
+ return found, kind
81
+ return None
82
+
83
+
84
+ def pptx_metrics(pptx: Path) -> dict:
85
+ """从 PPTX 包内直接读结构指标(零依赖,与 validate_pptx 同口径的轻量版)。"""
86
+ import zipfile
87
+ out = {'slide_count': 0, 'charts': 0, 'tables': 0, 'pictures': 0, 'themes': []}
88
+ try:
89
+ with zipfile.ZipFile(pptx) as zf:
90
+ names = [n for n in zf.namelist() if re.match(r'ppt/slides/slide\d+\.xml$', n)]
91
+ out['slide_count'] = len(names)
92
+ for name in sorted(names, key=lambda n: int(re.search(r'(\d+)', n.split('/')[-1]).group(1))):
93
+ root = ET.fromstring(zf.read(name))
94
+ out['charts'] += len(root.findall('.//c:chart', NS))
95
+ out['tables'] += len(root.findall('.//a:tbl', NS))
96
+ out['pictures'] += len(root.findall('.//p:pic', NS))
97
+ except (OSError, zipfile.BadZipFile, ET.ParseError, AttributeError):
98
+ pass
99
+ return out
100
+
101
+
102
+ def html_metrics(html: Path) -> dict:
103
+ """HTML 侧结构指标:band 数 / data-theme / data-mode / 图表标记数。"""
104
+ try:
105
+ txt = html.read_text(encoding='utf-8')
106
+ except OSError:
107
+ return {}
108
+ theme = re.search(r'<html[^>]*data-theme="([^"]+)"', txt)
109
+ mode = re.search(r'<html[^>]*data-mode="([^"]+)"', txt)
110
+ model = re.search(r'window\.REPORT_MODEL\s*=\s*(\{[\s\S]*?\})\s*;', txt)
111
+ sections = 0
112
+ if model:
113
+ try:
114
+ sections = len((json.loads(model.group(1)).get('sections') or []))
115
+ except json.JSONDecodeError:
116
+ sections = 0
117
+ return {
118
+ 'bands': len(re.findall(r'<section[^>]*class="[^"]*\bband\b', txt)),
119
+ 'theme': theme.group(1) if theme else None,
120
+ 'mode': mode.group(1) if mode else None,
121
+ 'sections': sections,
122
+ 'charts': len(re.findall(r'data-chart="', txt)),
123
+ }
124
+
125
+
126
+ def deviations(pptx_m: dict, html_m: dict, lc: dict) -> list[dict]:
127
+ """自动可算的偏差登记(人眼判定之外的可量化部分)。"""
128
+ devs: list[dict] = []
129
+ if not html_m:
130
+ return devs
131
+ bands, sections = html_m.get('bands', 0), html_m.get('sections', 0)
132
+ if bands and sections:
133
+ expected_lo, expected_hi = sections + 3, sections + 7
134
+ if not (expected_lo <= bands <= expected_hi):
135
+ devs.append({'kind': 'page-count', 'severity': 'medium',
136
+ 'detail': f'HTML band {bands} 不在模型 {sections} 章的预期区间 '
137
+ f'[{expected_lo},{expected_hi}](与 validate_report 同口径)'})
138
+ # 允许 PPTX 与 HTML 相差 1 页(architecture 可省 Agenda;声明式图片不增页)
139
+ if pptx_m.get('slide_count') and bands and abs(pptx_m['slide_count'] - bands) > 1:
140
+ devs.append({'kind': 'page-count-mismatch', 'severity': 'medium',
141
+ 'detail': f'PPTX {pptx_m["slide_count"]} 页 vs HTML {bands} 页(差 >1,需逐页核对)'})
142
+ n_charts = html_m.get('charts', 0)
143
+ if n_charts and pptx_m.get('charts', 0) == 0:
144
+ devs.append({'kind': 'chart-channel', 'severity': 'high',
145
+ 'detail': f'HTML 有 {n_charts} 个图表标记,但 PPTX 无 chart part——'
146
+ '若为形状通道图表属预期(需附数据表),否则应走原生通道'})
147
+ return devs
148
+
149
+
150
+ def write_compare_html(out: Path, pptx: Path, html: Path | None,
151
+ pdf: Path | None, pngs: list[Path], devs: list[dict],
152
+ pptx_m: dict, html_m: dict) -> Path:
153
+ def rows() -> str:
154
+ if not devs:
155
+ return '<tr><td colspan="3">自动可算项未发现偏差(视觉语义仍需人眼逐页对照)</td></tr>'
156
+ return ''.join(
157
+ f'<tr><td>{d["kind"]}</td><td>{d["severity"]}</td><td>{d["detail"]}</td></tr>'
158
+ for d in devs)
159
+
160
+ def png_gallery() -> str:
161
+ if not pngs:
162
+ return '<p class="note">未找到 PDF 位图工具(pdftoppm / mutool / magick)——右栏退化为 PDF 内嵌。</p>'
163
+ return ''.join(f'<figure><img src="{p.name}" alt="page"><figcaption>{p.stem}</figcaption></figure>'
164
+ for p in pngs)
165
+
166
+ left = (f'<iframe src="{html.resolve().as_uri()}" title="HTML 报告"></iframe>' if html
167
+ else '<p class="note">未提供 --html,无法并排对照 HTML 侧。</p>')
168
+ right = (f'<embed src="{pdf.name}" type="application/pdf" />' if pdf
169
+ else '<p class="note">未渲染 PDF(缺 LibreOffice)。</p>')
170
+ doc = f'''<!doctype html><html lang="zh-CN"><head><meta charset="utf-8">
171
+ <title>渲染对照 · {pptx.name}</title>
172
+ <style>
173
+ :root{{--bd:#dadce0;--tx:#202124;--bg:#fff;--sf:#f8f9fa}}
174
+ *{{box-sizing:border-box}}body{{margin:0;font:14px/1.6 system-ui,"Microsoft YaHei",sans-serif;color:var(--tx);background:var(--bg)}}
175
+ header{{padding:16px 24px;border-bottom:1px solid var(--bd)}}
176
+ h1{{margin:0 0 4px;font-size:18px}}
177
+ .meta{{color:#5f6368;font-size:13px}}
178
+ .grid{{display:grid;grid-template-columns:1fr 1fr;gap:1px;background:var(--bd);height:70vh}}
179
+ .grid>div{{background:var(--bg);overflow:auto}}
180
+ iframe,embed{{width:100%;height:100%;border:0;display:block}}
181
+ table{{border-collapse:collapse;width:100%;margin:16px 0}}
182
+ th,td{{border:1px solid var(--bd);padding:8px 10px;text-align:left;vertical-align:top;font-size:13px}}
183
+ th{{background:var(--sf)}}
184
+ .gallery{{display:flex;flex-wrap:wrap;gap:12px;padding:0 24px 24px}}
185
+ figure{{margin:0;width:320px}}img{{width:100%;border:1px solid var(--bd);border-radius:6px}}
186
+ figcaption{{font-size:12px;color:#5f6368;padding-top:4px}}
187
+ .note{{padding:16px 24px;color:#5f6368}}
188
+ section{{padding:0 24px 8px}}
189
+ </style></head><body>
190
+ <header><h1>渲染对照 · {pptx.name}</h1>
191
+ <div class="meta">PPTX {pptx_m.get('slide_count', 0)} 页 · chart part {pptx_m.get('charts', 0)} · 表格 {pptx_m.get('tables', 0)} · 图片 {pptx_m.get('pictures', 0)}
192
+ {' · HTML ' + str(html_m.get('bands', 0)) + ' 页(' + str(html_m.get('mode') or '?') + '/' + str(html_m.get('theme') or '?') + ')' if html_m else ''}</div></header>
193
+ <section><h2 style="font-size:15px;margin:16px 0 0">自动可算偏差登记</h2></section>
194
+ <table><thead><tr><th>类型</th><th>严重度</th><th>说明</th></tr></thead><tbody>{rows()}</tbody></table>
195
+ <div class="grid"><div>{left}</div><div>{right}</div></div>
196
+ <section><h2 style="font-size:15px">PPTX 逐页位图</h2></section>
197
+ <div class="gallery">{png_gallery()}</div>
198
+ </body></html>'''
199
+ target = out / 'compare.html'
200
+ target.write_text(doc, encoding='utf-8')
201
+ return target
202
+
203
+
204
+ def main(argv: list[str] | None = None) -> int:
205
+ parser = argparse.ArgumentParser(description='TopPPT HTML 深度模式渲染对照(可选工具)')
206
+ parser.add_argument('pptx', help='交付 PPTX 路径')
207
+ parser.add_argument('--html', help='对应 HTML 报告路径(可选,用于并排对照)')
208
+ parser.add_argument('--out', help='输出目录(默认 <pptx 同目录>/render-compare)')
209
+ parser.add_argument('--dpi', type=int, default=96, help='位图 DPI(默认 96)')
210
+ args = parser.parse_args(argv)
211
+
212
+ pptx = Path(args.pptx).resolve()
213
+ if not pptx.exists():
214
+ print(f'错误: 找不到 {pptx}')
215
+ return 2
216
+ out = Path(args.out).resolve() if args.out else pptx.parent / 'render-compare'
217
+ out.mkdir(parents=True, exist_ok=True)
218
+
219
+ lc = load_constants()
220
+ pptx_m = pptx_metrics(pptx)
221
+ html_path = Path(args.html).resolve() if args.html else None
222
+ html_m = html_metrics(html_path) if html_path and html_path.exists() else {}
223
+ devs = deviations(pptx_m, html_m, lc)
224
+
225
+ pdf: Path | None = None
226
+ pngs: list[Path] = []
227
+ soffice = find_soffice()
228
+ if soffice:
229
+ print(f'LibreOffice: {soffice}')
230
+ try:
231
+ subprocess.run([soffice, '--headless', '--norestore', '--convert-to', 'pdf',
232
+ '--outdir', str(out), str(pptx)],
233
+ check=True, capture_output=True, timeout=300)
234
+ cand = out / (pptx.stem + '.pdf')
235
+ pdf = cand if cand.exists() else None
236
+ print(f' 已渲染: {pdf.name}' if pdf else ' 渲染完成但未找到 PDF 产物')
237
+ except (subprocess.SubprocessError, OSError) as exc:
238
+ print(f' 渲染失败(跳过,不阻断交付): {exc}')
239
+ else:
240
+ print('未找到 LibreOffice(soffice)——跳过 PPTX 渲染。')
241
+ print(' 安装后可重跑:Windows https://www.libreoffice.org/download/ ;')
242
+ print(' 或用环境变量 SOFFICE 指定 soffice 可执行文件路径。')
243
+
244
+ raster = find_pdf_rasterizer()
245
+ if pdf and raster:
246
+ exe, kind = raster
247
+ try:
248
+ if kind == 'pdftoppm':
249
+ subprocess.run([exe, '-r', str(args.dpi), '-png', str(pdf), str(out / 'page')],
250
+ check=True, capture_output=True, timeout=300)
251
+ elif kind == 'mutool':
252
+ subprocess.run([exe, 'draw', '-r', str(args.dpi), '-o', str(out / 'page-%02d.png'), str(pdf)],
253
+ check=True, capture_output=True, timeout=300)
254
+ else:
255
+ subprocess.run([exe, '-density', str(args.dpi), str(pdf), str(out / 'page-%02d.png')],
256
+ check=True, capture_output=True, timeout=300)
257
+ pngs = sorted(out.glob('page*.png'))
258
+ print(f' 逐页位图: {len(pngs)} 张')
259
+ except (subprocess.SubprocessError, OSError) as exc:
260
+ print(f' 位图化失败(跳过): {exc}')
261
+ elif pdf:
262
+ print('未找到 PDF 位图工具(pdftoppm / mutool / magick)——保留 PDF,对照页右栏内嵌 PDF。')
263
+
264
+ compare = write_compare_html(out, pptx, html_path, pdf, pngs, devs, pptx_m, html_m)
265
+ (out / 'deviations.json').write_text(
266
+ json.dumps({'pptx': pptx_m, 'html': html_m, 'deviations': devs}, ensure_ascii=False, indent=2) + '\n',
267
+ encoding='utf-8')
268
+ print(f'对照页: {compare}')
269
+ print(f'偏差登记: {out / "deviations.json"}({len(devs)} 项自动可算偏差)')
270
+ print('提示:视觉语义(底色系统 / 图表形态 / 留白节奏)仍需人眼在对照页上逐页确认。')
271
+ return 0
272
+
273
+
274
+ if __name__ == '__main__':
275
+ sys.exit(main())