@topmindspace/tms-skills 2.0.1 → 2.0.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.
- package/CHANGELOG.md +139 -134
- package/bin/tms-skills.js +160 -160
- package/package.json +48 -48
- package/top-ppt-html/README.md +254 -254
- package/top-ppt-html/SKILL.md +111 -111
- package/top-ppt-html/assets/examples/2026-09-09-architecture-graphite-dark.html +3873 -3926
- package/top-ppt-html/assets/examples/2026-09-09-architecture-graphite-dark.model.json +167 -167
- package/top-ppt-html/assets/examples/2026-09-09-architecture-spectrum.html +3873 -3926
- package/top-ppt-html/assets/examples/2026-09-09-architecture-spectrum.model.json +167 -167
- package/top-ppt-html/assets/examples/2026-09-09-presentation-apple-mono.html +4272 -4325
- package/top-ppt-html/assets/examples/2026-09-09-presentation-apple-mono.model.json +320 -320
- package/top-ppt-html/assets/examples/2026-09-09-presentation-brand-red.html +4272 -4325
- package/top-ppt-html/assets/examples/2026-09-09-presentation-brand-red.model.json +320 -320
- package/top-ppt-html/assets/examples/2026-09-09-presentation-business-blue.html +4272 -4325
- package/top-ppt-html/assets/examples/2026-09-09-presentation-business-blue.model.json +320 -320
- package/top-ppt-html/assets/examples/2026-09-09-research-deep-teal.html +5474 -5527
- package/top-ppt-html/assets/examples/2026-09-09-research-deep-teal.model.json +913 -913
- package/top-ppt-html/assets/examples/2026-09-09-research-indigo-violet.html +5474 -5527
- package/top-ppt-html/assets/examples/2026-09-09-research-indigo-violet.model.json +913 -913
- package/top-ppt-html/assets/examples/2026-09-09-research-mckinsey.html +5474 -5527
- package/top-ppt-html/assets/examples/2026-09-09-research-mckinsey.model.json +913 -913
- package/top-ppt-html/assets/examples/2026-09-09-research-warm-sand.html +5474 -5527
- package/top-ppt-html/assets/examples/2026-09-09-research-warm-sand.model.json +913 -913
- package/top-ppt-html/assets/pptx-export.js +1944 -1944
- package/top-ppt-html/assets/style-gallery.html +559 -589
- package/top-ppt-html/assets/templates/architecture.html +3675 -3728
- package/top-ppt-html/assets/templates/engine.css +787 -840
- package/top-ppt-html/assets/templates/presentation.html +3685 -3738
- package/top-ppt-html/assets/templates/research.html +3964 -4017
- package/top-ppt-html/assets/templates/ui.js +520 -520
- package/top-ppt-html/assets/theme-overview-architecture.png +0 -0
- package/top-ppt-html/assets/theme-overview-presentation.png +0 -0
- package/top-ppt-html/assets/theme-overview-research.png +0 -0
- package/top-ppt-html/assets/theme-overview.png +0 -0
- package/top-ppt-html/evals/prompts.csv +15 -15
- package/top-ppt-html/evals/rubric.schema.json +25 -25
- package/top-ppt-html/evals/run_evals.py +220 -220
- package/top-ppt-html/evals/trace.example.json +16 -16
- package/top-ppt-html/package-lock.json +175 -0
- package/top-ppt-html/package.json +30 -35
- package/top-ppt-html/references/charts-basic.md +624 -624
- package/top-ppt-html/references/charts-discipline.md +108 -108
- package/top-ppt-html/references/charts-extended.md +482 -482
- package/top-ppt-html/references/charts.md +28 -28
- package/top-ppt-html/references/components-atoms.md +624 -624
- package/top-ppt-html/references/components.md +30 -30
- package/top-ppt-html/references/content-rules.md +490 -490
- package/top-ppt-html/references/design-system-engine.md +235 -235
- package/top-ppt-html/references/design-system.md +471 -478
- package/top-ppt-html/references/failure-modes.md +214 -214
- package/top-ppt-html/references/high-fidelity.md +127 -127
- package/top-ppt-html/references/icons.md +397 -397
- package/top-ppt-html/references/industry-benchmark.md +105 -105
- package/top-ppt-html/references/infographics-stats.md +308 -308
- package/top-ppt-html/references/infographics-structure.md +226 -226
- package/top-ppt-html/references/infographics.md +43 -43
- package/top-ppt-html/references/layout-grammar.md +315 -315
- package/top-ppt-html/references/layouts-architecture.md +108 -108
- package/top-ppt-html/references/layouts-combo.md +600 -600
- package/top-ppt-html/references/layouts-research.md +160 -160
- package/top-ppt-html/references/modes.md +254 -254
- package/top-ppt-html/references/outline-design.md +275 -275
- package/top-ppt-html/references/playbook.md +266 -266
- package/top-ppt-html/references/pptx-export.md +209 -209
- package/top-ppt-html/references/reform-plan.md +252 -252
- package/top-ppt-html/references/styles.md +336 -370
- package/top-ppt-html/references/tech-design.md +138 -138
- package/top-ppt-html/scripts/audit_css.py +109 -109
- package/top-ppt-html/scripts/audit_docs.py +176 -176
- package/top-ppt-html/scripts/audit_skill.py +220 -220
- package/top-ppt-html/scripts/audit_styles.py +293 -351
- package/top-ppt-html/scripts/build_examples.py +2276 -2276
- package/top-ppt-html/scripts/build_pptx.js +2380 -2380
- package/top-ppt-html/scripts/capture_theme_overview.js +78 -78
- package/top-ppt-html/scripts/checks_html.py +127 -127
- package/top-ppt-html/scripts/cross_verify.py +294 -294
- package/top-ppt-html/scripts/env_probe.py +158 -158
- package/top-ppt-html/scripts/extract_model.py +210 -210
- package/top-ppt-html/scripts/extract_snippet.py +374 -374
- package/top-ppt-html/scripts/gen_channel_a.js +214 -214
- package/top-ppt-html/scripts/layout-constants.json +3309 -3377
- package/top-ppt-html/scripts/layout_slots.json +830 -830
- package/top-ppt-html/scripts/lib_layout_regions.js +412 -412
- package/top-ppt-html/scripts/measure_height.py +178 -178
- package/top-ppt-html/scripts/model-schema.json +547 -547
- package/top-ppt-html/scripts/negative_tests.py +307 -307
- package/top-ppt-html/scripts/package_skill.py +291 -291
- package/top-ppt-html/scripts/prepare_images.py +341 -341
- package/top-ppt-html/scripts/probe_image_export.py +188 -188
- package/top-ppt-html/scripts/quality_gate.py +301 -301
- package/top-ppt-html/scripts/regression.py +307 -308
- package/top-ppt-html/scripts/render_compare.py +275 -275
- package/top-ppt-html/scripts/render_from_model.py +698 -698
- package/top-ppt-html/scripts/scaffold_report.py +1054 -1054
- package/top-ppt-html/scripts/section-file-map.json +104 -104
- package/top-ppt-html/scripts/sync_runtime.py +662 -662
- package/top-ppt-html/scripts/validate_pptx.py +1510 -1510
- 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())
|