@topmindspace/tms-skills 2.0.1 → 2.1.0

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 (95) hide show
  1. package/CHANGELOG.md +145 -134
  2. package/README.md +1 -1
  3. package/bin/tms-skills.js +160 -160
  4. package/package.json +48 -48
  5. package/top-ppt-html/README.md +254 -254
  6. package/top-ppt-html/SKILL.md +111 -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-architecture-spectrum.html +3926 -3926
  10. package/top-ppt-html/assets/examples/2026-09-09-architecture-spectrum.model.json +167 -167
  11. package/top-ppt-html/assets/examples/2026-09-09-presentation-apple-mono.html +4325 -4325
  12. package/top-ppt-html/assets/examples/2026-09-09-presentation-apple-mono.model.json +320 -320
  13. package/top-ppt-html/assets/examples/2026-09-09-presentation-brand-red.html +4325 -4325
  14. package/top-ppt-html/assets/examples/2026-09-09-presentation-brand-red.model.json +320 -320
  15. package/top-ppt-html/assets/examples/2026-09-09-presentation-business-blue.html +4325 -4325
  16. package/top-ppt-html/assets/examples/2026-09-09-presentation-business-blue.model.json +320 -320
  17. package/top-ppt-html/assets/examples/2026-09-09-research-deep-teal.html +5527 -5527
  18. package/top-ppt-html/assets/examples/2026-09-09-research-deep-teal.model.json +913 -913
  19. package/top-ppt-html/assets/examples/2026-09-09-research-indigo-violet.html +5527 -5527
  20. package/top-ppt-html/assets/examples/2026-09-09-research-indigo-violet.model.json +913 -913
  21. package/top-ppt-html/assets/examples/2026-09-09-research-mckinsey.html +5527 -5527
  22. package/top-ppt-html/assets/examples/2026-09-09-research-mckinsey.model.json +913 -913
  23. package/top-ppt-html/assets/examples/2026-09-09-research-warm-sand.html +5527 -5527
  24. package/top-ppt-html/assets/examples/2026-09-09-research-warm-sand.model.json +913 -913
  25. package/top-ppt-html/assets/pptx-export.js +1944 -1944
  26. package/top-ppt-html/assets/style-gallery.html +589 -589
  27. package/top-ppt-html/assets/templates/architecture.html +3728 -3728
  28. package/top-ppt-html/assets/templates/engine.css +840 -840
  29. package/top-ppt-html/assets/templates/presentation.html +3738 -3738
  30. package/top-ppt-html/assets/templates/research.html +4017 -4017
  31. package/top-ppt-html/assets/templates/ui.js +520 -520
  32. package/top-ppt-html/evals/prompts.csv +15 -15
  33. package/top-ppt-html/evals/rubric.schema.json +25 -25
  34. package/top-ppt-html/evals/run_evals.py +220 -220
  35. package/top-ppt-html/evals/trace.example.json +16 -16
  36. package/top-ppt-html/package-lock.json +207 -0
  37. package/top-ppt-html/package.json +35 -35
  38. package/top-ppt-html/references/charts-basic.md +624 -624
  39. package/top-ppt-html/references/charts-discipline.md +108 -108
  40. package/top-ppt-html/references/charts-extended.md +482 -482
  41. package/top-ppt-html/references/charts.md +28 -28
  42. package/top-ppt-html/references/components-atoms.md +624 -624
  43. package/top-ppt-html/references/components.md +30 -30
  44. package/top-ppt-html/references/content-rules.md +490 -490
  45. package/top-ppt-html/references/design-system-engine.md +235 -235
  46. package/top-ppt-html/references/design-system.md +478 -478
  47. package/top-ppt-html/references/failure-modes.md +214 -214
  48. package/top-ppt-html/references/high-fidelity.md +127 -127
  49. package/top-ppt-html/references/icons.md +397 -397
  50. package/top-ppt-html/references/industry-benchmark.md +105 -105
  51. package/top-ppt-html/references/infographics-stats.md +308 -308
  52. package/top-ppt-html/references/infographics-structure.md +226 -226
  53. package/top-ppt-html/references/infographics.md +43 -43
  54. package/top-ppt-html/references/layout-grammar.md +315 -315
  55. package/top-ppt-html/references/layouts-architecture.md +108 -108
  56. package/top-ppt-html/references/layouts-combo.md +600 -600
  57. package/top-ppt-html/references/layouts-research.md +160 -160
  58. package/top-ppt-html/references/modes.md +254 -254
  59. package/top-ppt-html/references/outline-design.md +275 -275
  60. package/top-ppt-html/references/playbook.md +266 -266
  61. package/top-ppt-html/references/pptx-export.md +209 -209
  62. package/top-ppt-html/references/reform-plan.md +252 -252
  63. package/top-ppt-html/references/styles.md +370 -370
  64. package/top-ppt-html/references/tech-design.md +138 -138
  65. package/top-ppt-html/scripts/audit_css.py +109 -109
  66. package/top-ppt-html/scripts/audit_docs.py +176 -176
  67. package/top-ppt-html/scripts/audit_skill.py +220 -220
  68. package/top-ppt-html/scripts/audit_styles.py +351 -351
  69. package/top-ppt-html/scripts/build_examples.py +2276 -2276
  70. package/top-ppt-html/scripts/build_pptx.js +2380 -2380
  71. package/top-ppt-html/scripts/capture_theme_overview.js +78 -78
  72. package/top-ppt-html/scripts/checks_html.py +127 -127
  73. package/top-ppt-html/scripts/cross_verify.py +294 -294
  74. package/top-ppt-html/scripts/env_probe.py +158 -158
  75. package/top-ppt-html/scripts/extract_model.py +210 -210
  76. package/top-ppt-html/scripts/extract_snippet.py +374 -374
  77. package/top-ppt-html/scripts/gen_channel_a.js +214 -214
  78. package/top-ppt-html/scripts/layout-constants.json +3377 -3377
  79. package/top-ppt-html/scripts/layout_slots.json +830 -830
  80. package/top-ppt-html/scripts/lib_layout_regions.js +412 -412
  81. package/top-ppt-html/scripts/measure_height.py +178 -178
  82. package/top-ppt-html/scripts/model-schema.json +547 -547
  83. package/top-ppt-html/scripts/negative_tests.py +307 -307
  84. package/top-ppt-html/scripts/package_skill.py +291 -291
  85. package/top-ppt-html/scripts/prepare_images.py +341 -341
  86. package/top-ppt-html/scripts/probe_image_export.py +188 -188
  87. package/top-ppt-html/scripts/quality_gate.py +301 -301
  88. package/top-ppt-html/scripts/regression.py +308 -308
  89. package/top-ppt-html/scripts/render_compare.py +275 -275
  90. package/top-ppt-html/scripts/render_from_model.py +698 -698
  91. package/top-ppt-html/scripts/scaffold_report.py +1054 -1054
  92. package/top-ppt-html/scripts/section-file-map.json +104 -104
  93. package/top-ppt-html/scripts/sync_runtime.py +662 -662
  94. package/top-ppt-html/scripts/validate_pptx.py +1510 -1510
  95. package/top-ppt-html/scripts/validate_report.py +1456 -1456
@@ -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())