@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.
- package/CHANGELOG.md +145 -134
- package/README.md +1 -1
- 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 +3926 -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 +3926 -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 +4325 -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 +4325 -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 +4325 -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 +5527 -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 +5527 -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 +5527 -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 +5527 -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 +589 -589
- package/top-ppt-html/assets/templates/architecture.html +3728 -3728
- package/top-ppt-html/assets/templates/engine.css +840 -840
- package/top-ppt-html/assets/templates/presentation.html +3738 -3738
- package/top-ppt-html/assets/templates/research.html +4017 -4017
- package/top-ppt-html/assets/templates/ui.js +520 -520
- 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 +207 -0
- package/top-ppt-html/package.json +35 -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 +478 -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 +370 -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 +351 -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 +3377 -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 +308 -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,341 +1,341 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
# -*- coding: utf-8 -*-
|
|
3
|
-
"""TopPPT HTML · 用户图片素材准备
|
|
4
|
-
|
|
5
|
-
把用户提供的图片处理成**可直接放进报告**的素材:
|
|
6
|
-
① 读尺寸/格式/体积(纯标准库解析 PNG/JPEG/GIF/WebP/SVG 头部,无需第三方依赖);
|
|
7
|
-
② 按 imageSpec.recommendedSizePx 检查是否够大(不足会提示,避免放大糊);
|
|
8
|
-
③ 需要时缩放/压缩(检测到 Pillow 才启用,缺失自动跳过并给出建议);
|
|
9
|
-
④ 生成两种落地形态:**data: 内联**(单文件零外链,随报告走)或 **相对路径**(体积大时推荐);
|
|
10
|
-
⑤ 直接产出可粘贴的 HTML 片段与 REPORT_MODEL 的 image 对象片段。
|
|
11
|
-
|
|
12
|
-
用法:
|
|
13
|
-
python scripts/prepare_images.py <图片|目录> [更多图片…]
|
|
14
|
-
[--layout full|half|bleed|grid|compare|wall] 默认 full
|
|
15
|
-
[--out <目录>] 输出目录(默认当前目录下的 report-assets/)
|
|
16
|
-
[--mode inline|path] inline=base64 内联(默认,受体积上限约束);path=复制到 out 并用相对路径
|
|
17
|
-
[--max-bytes N] 单图内联上限(默认取 layout-constants.json imageSpec.maxInlineBytes)
|
|
18
|
-
[--json] 只打印机器可读 JSON
|
|
19
|
-
[--no-resize] 禁用缩放(即使 Pillow 可用)
|
|
20
|
-
|
|
21
|
-
产出(写到 --out 目录):
|
|
22
|
-
images-snippets.html HTML 图片片段(.media 族,含占位示例)
|
|
23
|
-
images-model.json REPORT_MODEL 的 image 对象片段(可直接并入 sections[])
|
|
24
|
-
|
|
25
|
-
依赖:仅标准库;可选 Pillow(有则缩放/压缩,无则原样内联并提示)。
|
|
26
|
-
"""
|
|
27
|
-
from __future__ import annotations
|
|
28
|
-
|
|
29
|
-
import argparse
|
|
30
|
-
import base64
|
|
31
|
-
import json
|
|
32
|
-
import re
|
|
33
|
-
import shutil
|
|
34
|
-
import sys
|
|
35
|
-
from pathlib import Path
|
|
36
|
-
|
|
37
|
-
try:
|
|
38
|
-
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
|
|
39
|
-
sys.stderr.reconfigure(encoding='utf-8', errors='replace')
|
|
40
|
-
except Exception:
|
|
41
|
-
pass
|
|
42
|
-
|
|
43
|
-
ROOT = Path(__file__).resolve().parent.parent
|
|
44
|
-
LC_PATH = Path(__file__).resolve().parent / 'layout-constants.json'
|
|
45
|
-
IMAGE_EXT = {'.png', '.jpg', '.jpeg', '.webp', '.gif', '.svg'}
|
|
46
|
-
MIME = {'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
47
|
-
'.webp': 'image/webp', '.gif': 'image/gif', '.svg': 'image/svg+xml'}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
def load_image_spec() -> dict:
|
|
51
|
-
try:
|
|
52
|
-
lc = json.loads(LC_PATH.read_text(encoding='utf-8'))
|
|
53
|
-
spec = lc.get('imageSpec') or {}
|
|
54
|
-
return spec if isinstance(spec, dict) else {}
|
|
55
|
-
except (OSError, json.JSONDecodeError):
|
|
56
|
-
return {}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
SPEC = load_image_spec()
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
def ratio_num(spec: str) -> float:
|
|
63
|
-
"""'3:1' → 3.0;非法返回 0。"""
|
|
64
|
-
m = re.match(r'\s*(\d+(?:\.\d+)?)\s*[:/×x]\s*(\d+(?:\.\d+)?)', str(spec or ''))
|
|
65
|
-
if not m:
|
|
66
|
-
return 0.0
|
|
67
|
-
a, b = float(m.group(1)), float(m.group(2))
|
|
68
|
-
return a / b if a > 0 and b > 0 else 0.0
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
def target_width(layout: str) -> int:
|
|
72
|
-
"""imageSpec.recommendedSizePx 形如 '2000×1125' → 2000。"""
|
|
73
|
-
raw = str((SPEC.get('recommendedSizePx') or {}).get(layout) or '2000×1125')
|
|
74
|
-
m = re.match(r'\s*(\d+)', raw)
|
|
75
|
-
return int(m.group(1)) if m else 2000
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
def read_size(path: Path) -> tuple[int, int] | None:
|
|
79
|
-
"""纯标准库读位图尺寸(PNG/JPEG/GIF/WebP);SVG 走文本解析。"""
|
|
80
|
-
try:
|
|
81
|
-
data = path.read_bytes()[:65536]
|
|
82
|
-
except OSError:
|
|
83
|
-
return None
|
|
84
|
-
if len(data) < 16:
|
|
85
|
-
return None
|
|
86
|
-
# PNG
|
|
87
|
-
if data[:8] == b'\x89PNG\r\n\x1a\n':
|
|
88
|
-
return int.from_bytes(data[16:20], 'big'), int.from_bytes(data[20:24], 'big')
|
|
89
|
-
# GIF
|
|
90
|
-
if data[:6] in (b'GIF87a', b'GIF89a'):
|
|
91
|
-
return int.from_bytes(data[6:8], 'little'), int.from_bytes(data[8:10], 'little')
|
|
92
|
-
# JPEG:扫描 SOF 段
|
|
93
|
-
if data[:2] == b'\xff\xd8':
|
|
94
|
-
i = 2
|
|
95
|
-
while i + 9 < len(data):
|
|
96
|
-
if data[i] != 0xFF:
|
|
97
|
-
i += 1
|
|
98
|
-
continue
|
|
99
|
-
marker = data[i + 1]
|
|
100
|
-
if marker in (0xD8, 0x01) or 0xD0 <= marker <= 0xD7:
|
|
101
|
-
i += 2
|
|
102
|
-
continue
|
|
103
|
-
if marker == 0xD9:
|
|
104
|
-
break
|
|
105
|
-
seg_len = int.from_bytes(data[i + 2:i + 4], 'big')
|
|
106
|
-
if 0xC0 <= marker <= 0xCF and marker not in (0xC4, 0xC8, 0xCC):
|
|
107
|
-
h = int.from_bytes(data[i + 5:i + 7], 'big')
|
|
108
|
-
w = int.from_bytes(data[i + 7:i + 9], 'big')
|
|
109
|
-
return w, h
|
|
110
|
-
i += 2 + max(2, seg_len)
|
|
111
|
-
return None
|
|
112
|
-
# WebP
|
|
113
|
-
if data[:4] == b'RIFF' and data[8:12] == b'WEBP':
|
|
114
|
-
chunk = data[12:16]
|
|
115
|
-
if chunk == b'VP8X' and len(data) >= 30:
|
|
116
|
-
w = int.from_bytes(data[24:27], 'little') + 1
|
|
117
|
-
h = int.from_bytes(data[27:30], 'little') + 1
|
|
118
|
-
return w, h
|
|
119
|
-
if chunk == b'VP8 ' and len(data) >= 30:
|
|
120
|
-
w = int.from_bytes(data[26:28], 'little') & 0x3FFF
|
|
121
|
-
h = int.from_bytes(data[28:30], 'little') & 0x3FFF
|
|
122
|
-
return w, h
|
|
123
|
-
if chunk == b'VP8L' and len(data) >= 25:
|
|
124
|
-
bits = int.from_bytes(data[21:25], 'little')
|
|
125
|
-
return (bits & 0x3FFF) + 1, ((bits >> 14) & 0x3FFF) + 1
|
|
126
|
-
return None
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
def read_svg_size(path: Path) -> tuple[int, int] | None:
|
|
130
|
-
try:
|
|
131
|
-
head = path.read_text(encoding='utf-8', errors='replace')[:2048]
|
|
132
|
-
except OSError:
|
|
133
|
-
return None
|
|
134
|
-
w = re.search(r'width\s*=\s*["\']?\s*(\d+(?:\.\d+)?)', head)
|
|
135
|
-
h = re.search(r'height\s*=\s*["\']?\s*(\d+(?:\.\d+)?)', head)
|
|
136
|
-
if w and h:
|
|
137
|
-
return int(float(w.group(1))), int(float(h.group(1)))
|
|
138
|
-
vb = re.search(r'viewBox\s*=\s*["\']([\d.\-\s,]+)["\']', head)
|
|
139
|
-
if vb:
|
|
140
|
-
nums = re.split(r'[\s,]+', vb.group(1).strip())
|
|
141
|
-
if len(nums) == 4:
|
|
142
|
-
return int(float(nums[2])), int(float(nums[3]))
|
|
143
|
-
return None
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
def ratio_label(w: int, h: int) -> str:
|
|
147
|
-
if not w or not h:
|
|
148
|
-
return '未知'
|
|
149
|
-
from math import gcd
|
|
150
|
-
g = gcd(w, h) or 1
|
|
151
|
-
a, b = w // g, h // g
|
|
152
|
-
if a > 40 or b > 40:
|
|
153
|
-
return f'{w / h:.2f}:1'
|
|
154
|
-
return f'{a}:{b}'
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
def try_resize(src: Path, dst: Path, want_w: int) -> bool:
|
|
158
|
-
"""有 Pillow 时缩放/压缩;无则返回 False(调用方回落原图)。"""
|
|
159
|
-
try:
|
|
160
|
-
from PIL import Image # type: ignore
|
|
161
|
-
except Exception:
|
|
162
|
-
return False
|
|
163
|
-
try:
|
|
164
|
-
with Image.open(src) as im:
|
|
165
|
-
im.load()
|
|
166
|
-
w, h = im.size
|
|
167
|
-
if w > want_w:
|
|
168
|
-
im = im.resize((want_w, max(1, round(h * want_w / w))), Image.LANCZOS)
|
|
169
|
-
if dst.suffix.lower() in ('.jpg', '.jpeg'):
|
|
170
|
-
if im.mode in ('RGBA', 'P', 'LA'):
|
|
171
|
-
bg = Image.new('RGB', im.size, (255, 255, 255))
|
|
172
|
-
bg.paste(im.convert('RGBA'), mask=im.convert('RGBA').split()[-1])
|
|
173
|
-
im = bg
|
|
174
|
-
else:
|
|
175
|
-
im = im.convert('RGB')
|
|
176
|
-
im.save(dst, 'JPEG', quality=82, optimize=True, progressive=True)
|
|
177
|
-
else:
|
|
178
|
-
im.save(dst, optimize=True)
|
|
179
|
-
return True
|
|
180
|
-
except Exception:
|
|
181
|
-
return False
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
def collect_inputs(args_paths: list[str]) -> list[Path]:
|
|
185
|
-
out: list[Path] = []
|
|
186
|
-
for raw in args_paths:
|
|
187
|
-
p = Path(raw)
|
|
188
|
-
if p.is_dir():
|
|
189
|
-
out.extend(sorted(q for q in p.rglob('*') if q.suffix.lower() in IMAGE_EXT))
|
|
190
|
-
elif p.is_file():
|
|
191
|
-
out.append(p)
|
|
192
|
-
else:
|
|
193
|
-
print(f'[跳过] 不存在: {raw}')
|
|
194
|
-
seen, uniq = set(), []
|
|
195
|
-
for p in out:
|
|
196
|
-
if p.resolve() not in seen:
|
|
197
|
-
seen.add(p.resolve())
|
|
198
|
-
uniq.append(p)
|
|
199
|
-
return uniq
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
def main() -> int:
|
|
203
|
-
ap = argparse.ArgumentParser(description='TopPPT HTML 用户图片素材准备')
|
|
204
|
-
ap.add_argument('paths', nargs='+', help='图片文件或目录(可多个)')
|
|
205
|
-
ap.add_argument('--layout', default='full', choices=list(SPEC.get('layouts') or
|
|
206
|
-
['full', 'half', 'bleed', 'grid', 'compare', 'wall']))
|
|
207
|
-
ap.add_argument('--out', default=None, help='输出目录(默认 ./report-assets)')
|
|
208
|
-
ap.add_argument('--mode', default='inline', choices=['inline', 'path'])
|
|
209
|
-
ap.add_argument('--max-bytes', type=int, default=int(SPEC.get('maxInlineBytes') or 1572864))
|
|
210
|
-
ap.add_argument('--json', action='store_true')
|
|
211
|
-
ap.add_argument('--no-resize', action='store_true')
|
|
212
|
-
args = ap.parse_args()
|
|
213
|
-
|
|
214
|
-
files = collect_inputs(args.paths)
|
|
215
|
-
if not files:
|
|
216
|
-
print('没有可处理的图片(支持 ' + '/'.join(sorted(e.lstrip(".") for e in IMAGE_EXT)) + ')。')
|
|
217
|
-
return 2
|
|
218
|
-
|
|
219
|
-
out_dir = Path(args.out) if args.out else Path.cwd() / 'report-assets'
|
|
220
|
-
out_dir.mkdir(parents=True, exist_ok=True)
|
|
221
|
-
assets_dir = out_dir / 'assets'
|
|
222
|
-
want_w = target_width(args.layout)
|
|
223
|
-
max_total = int(SPEC.get('maxTotalInlineBytes') or 8388608)
|
|
224
|
-
|
|
225
|
-
rows, html_bits, model_items, notes = [], [], [], []
|
|
226
|
-
total_inline = 0
|
|
227
|
-
for src in files:
|
|
228
|
-
ext = src.suffix.lower()
|
|
229
|
-
size = read_svg_size(src) if ext == '.svg' else read_size(src)
|
|
230
|
-
w, h = size if size else (0, 0)
|
|
231
|
-
work = src
|
|
232
|
-
resized = False
|
|
233
|
-
if not args.no_resize and w > want_w and ext != '.svg':
|
|
234
|
-
cand = out_dir / (src.stem + f'-{want_w}' + ('.jpg' if ext in ('.jpg', '.jpeg') else ext))
|
|
235
|
-
if try_resize(src, cand, want_w):
|
|
236
|
-
work, resized = cand, True
|
|
237
|
-
size = read_size(cand)
|
|
238
|
-
w, h = size if size else (w, h)
|
|
239
|
-
nbytes = work.stat().st_size
|
|
240
|
-
if not resized and w and w < want_w * 0.75:
|
|
241
|
-
notes.append(f'{src.name}: 宽 {w}px < 建议 {want_w}px(放大易糊;请换更高分辨率原图或改小版式)')
|
|
242
|
-
want_ratio = ratio_num((SPEC.get('ratioDefault') or {}).get(args.layout) or '')
|
|
243
|
-
fit_cls = ''
|
|
244
|
-
if w and h and want_ratio:
|
|
245
|
-
got = w / h
|
|
246
|
-
if abs(got - want_ratio) / want_ratio > 0.25:
|
|
247
|
-
fit_cls = ' media--contain' # 比例差得多 → HTML 也用 contain,与模型 fit 保持一致
|
|
248
|
-
notes.append(f'{src.name}: 实际比例 {got:.2f}:1 与 {args.layout} 版式锁定比例 '
|
|
249
|
-
f'{want_ratio:.2f}:1 相差较大 → 已按 fit:"contain" 输出(完整显示留白);'
|
|
250
|
-
f'若希望裁切填满请换更贴合比例的版式/素材')
|
|
251
|
-
if ext == '.svg':
|
|
252
|
-
notes.append(f'{src.name}: SVG 为矢量(好);注意外链字体/图片需内联,否则破坏零外链铁律')
|
|
253
|
-
|
|
254
|
-
use_path = args.mode == 'path' or nbytes > args.max_bytes
|
|
255
|
-
if use_path:
|
|
256
|
-
assets_dir.mkdir(parents=True, exist_ok=True)
|
|
257
|
-
dst = assets_dir / work.name
|
|
258
|
-
if work.resolve() != dst.resolve():
|
|
259
|
-
shutil.copy2(work, dst)
|
|
260
|
-
url = f'assets/{dst.name}'
|
|
261
|
-
form = '相对路径'
|
|
262
|
-
else:
|
|
263
|
-
b64 = base64.b64encode(work.read_bytes()).decode('ascii')
|
|
264
|
-
url = f'data:{MIME.get(ext, "image/png")};base64,{b64}'
|
|
265
|
-
form = 'data: 内联'
|
|
266
|
-
total_inline += len(url)
|
|
267
|
-
if nbytes > args.max_bytes and args.mode != 'path':
|
|
268
|
-
notes.append(f'{src.name}: {nbytes // 1024}KB > 内联上限 {args.max_bytes // 1024}KB → 已改用相对路径')
|
|
269
|
-
|
|
270
|
-
rows.append({'file': src.name, 'w': w, 'h': h, 'ratio': ratio_label(w, h),
|
|
271
|
-
'kb': nbytes // 1024, 'form': form, 'url': url,
|
|
272
|
-
'targetW': want_w, 'resized': resized})
|
|
273
|
-
lock_cls = (SPEC.get('ratioCssClass') or {}).get(args.layout) or f'media--r{ratio_class(w, h)}'
|
|
274
|
-
html_bits.append(
|
|
275
|
-
f'<figure class="media {lock_cls}{fit_cls} rv">\n'
|
|
276
|
-
f' <img src="{url if len(url) < 400 else url[:60] + "…(见 images-snippets.html 全文)"}" '
|
|
277
|
-
f'alt="{src.stem}">\n'
|
|
278
|
-
f' <figcaption class="media__cap--below">图:{src.stem}({w}×{h} · {ratio_label(w, h)})</figcaption>\n'
|
|
279
|
-
f'</figure>')
|
|
280
|
-
model_items.append({'src': url, 'alt': src.stem, 'caption': f'图:{src.stem}'})
|
|
281
|
-
|
|
282
|
-
if total_inline > max_total:
|
|
283
|
-
notes.append(f'内联总量 {total_inline // 1024}KB > 上限 {max_total // 1024}KB:'
|
|
284
|
-
'建议部分图片改用 --mode path(相对路径)')
|
|
285
|
-
|
|
286
|
-
layout = args.layout
|
|
287
|
-
if len(model_items) > 1 and layout not in (SPEC.get('multiLayouts') or ['grid', 'compare', 'wall']):
|
|
288
|
-
notes.append(f'共 {len(model_items)} 张图但 layout={layout}(单图版式)→ 多图请用 grid/compare/wall')
|
|
289
|
-
want_ratio = ratio_num((SPEC.get('ratioDefault') or {}).get(layout) or '')
|
|
290
|
-
fit = SPEC.get('fitDefault') or 'cover'
|
|
291
|
-
if want_ratio and any(r['w'] and r['h'] and abs(r['w'] / r['h'] - want_ratio) / want_ratio > 0.25
|
|
292
|
-
for r in rows):
|
|
293
|
-
fit = 'contain' # 比例差得多时默认完整显示,避免 cover 切掉关键内容
|
|
294
|
-
image_model = {'layout': layout, 'fit': fit,
|
|
295
|
-
'caption': f'图:{model_items[0]["alt"]}' if model_items else ''}
|
|
296
|
-
if len(model_items) == 1:
|
|
297
|
-
image_model['src'] = model_items[0]['src']
|
|
298
|
-
image_model['alt'] = model_items[0]['alt']
|
|
299
|
-
else:
|
|
300
|
-
image_model['items'] = model_items
|
|
301
|
-
|
|
302
|
-
(out_dir / 'images-snippets.html').write_text('\n\n'.join(html_bits), encoding='utf-8')
|
|
303
|
-
(out_dir / 'images-model.json').write_text(
|
|
304
|
-
json.dumps(image_model, ensure_ascii=False, indent=2), encoding='utf-8')
|
|
305
|
-
|
|
306
|
-
if args.json:
|
|
307
|
-
print(json.dumps({'layout': layout, 'out': str(out_dir), 'images': rows, 'notes': notes},
|
|
308
|
-
ensure_ascii=False, indent=2))
|
|
309
|
-
return 0
|
|
310
|
-
|
|
311
|
-
print(f'图片素材准备 · layout={layout} · 建议宽 {want_w}px · 共 {len(rows)} 张')
|
|
312
|
-
print(f'{"文件":<28}{"尺寸":<14}{"比例":<10}{"体积":<10}形态')
|
|
313
|
-
for r in rows:
|
|
314
|
-
dim = f'{r["w"]}×{r["h"]}' if r['w'] else '未知'
|
|
315
|
-
print(f'{r["file"][:26]:<28}{dim:<14}{r["ratio"]:<10}{str(r["kb"]) + "KB":<10}{r["form"]}'
|
|
316
|
-
+ ('(已缩放)' if r['resized'] else ''))
|
|
317
|
-
if notes:
|
|
318
|
-
print('\n提示:')
|
|
319
|
-
for n in notes:
|
|
320
|
-
print(' · ' + n)
|
|
321
|
-
print(f'\n已写出:\n {out_dir / "images-snippets.html"} (HTML .media 片段,粘进对应 section.band)')
|
|
322
|
-
print(f' {out_dir / "images-model.json"} (REPORT_MODEL 的 image 对象,并入 sections[])')
|
|
323
|
-
if args.mode == 'inline':
|
|
324
|
-
print(' 说明: 内联形态保持「单文件零外链」;体积大时改用 --mode path 并把 assets/ 目录随报告一起交付。')
|
|
325
|
-
print(' 占位: 暂无图时先在模型写 image.placeholder=true(正文用 .media--ph),交付前替换 src 即可。')
|
|
326
|
-
return 0
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
def ratio_class(w: int, h: int) -> str:
|
|
330
|
-
"""把实际比例归一到 .media--r* 锁定类(16-9 / 4-3 / 3-2 / 1-1 / 21-9)。"""
|
|
331
|
-
if not w or not h:
|
|
332
|
-
return '16-9'
|
|
333
|
-
r = w / h
|
|
334
|
-
for name, val in (('21-9', 21 / 9), ('16-9', 16 / 9), ('3-2', 1.5), ('4-3', 4 / 3), ('1-1', 1.0)):
|
|
335
|
-
if abs(r - val) / val <= 0.06:
|
|
336
|
-
return name
|
|
337
|
-
return '16-9' if r > 1.4 else ('1-1' if r > 0.9 else '4-3')
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
if __name__ == '__main__':
|
|
341
|
-
sys.exit(main())
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""TopPPT HTML · 用户图片素材准备
|
|
4
|
+
|
|
5
|
+
把用户提供的图片处理成**可直接放进报告**的素材:
|
|
6
|
+
① 读尺寸/格式/体积(纯标准库解析 PNG/JPEG/GIF/WebP/SVG 头部,无需第三方依赖);
|
|
7
|
+
② 按 imageSpec.recommendedSizePx 检查是否够大(不足会提示,避免放大糊);
|
|
8
|
+
③ 需要时缩放/压缩(检测到 Pillow 才启用,缺失自动跳过并给出建议);
|
|
9
|
+
④ 生成两种落地形态:**data: 内联**(单文件零外链,随报告走)或 **相对路径**(体积大时推荐);
|
|
10
|
+
⑤ 直接产出可粘贴的 HTML 片段与 REPORT_MODEL 的 image 对象片段。
|
|
11
|
+
|
|
12
|
+
用法:
|
|
13
|
+
python scripts/prepare_images.py <图片|目录> [更多图片…]
|
|
14
|
+
[--layout full|half|bleed|grid|compare|wall] 默认 full
|
|
15
|
+
[--out <目录>] 输出目录(默认当前目录下的 report-assets/)
|
|
16
|
+
[--mode inline|path] inline=base64 内联(默认,受体积上限约束);path=复制到 out 并用相对路径
|
|
17
|
+
[--max-bytes N] 单图内联上限(默认取 layout-constants.json imageSpec.maxInlineBytes)
|
|
18
|
+
[--json] 只打印机器可读 JSON
|
|
19
|
+
[--no-resize] 禁用缩放(即使 Pillow 可用)
|
|
20
|
+
|
|
21
|
+
产出(写到 --out 目录):
|
|
22
|
+
images-snippets.html HTML 图片片段(.media 族,含占位示例)
|
|
23
|
+
images-model.json REPORT_MODEL 的 image 对象片段(可直接并入 sections[])
|
|
24
|
+
|
|
25
|
+
依赖:仅标准库;可选 Pillow(有则缩放/压缩,无则原样内联并提示)。
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import argparse
|
|
30
|
+
import base64
|
|
31
|
+
import json
|
|
32
|
+
import re
|
|
33
|
+
import shutil
|
|
34
|
+
import sys
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
|
|
39
|
+
sys.stderr.reconfigure(encoding='utf-8', errors='replace')
|
|
40
|
+
except Exception:
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
44
|
+
LC_PATH = Path(__file__).resolve().parent / 'layout-constants.json'
|
|
45
|
+
IMAGE_EXT = {'.png', '.jpg', '.jpeg', '.webp', '.gif', '.svg'}
|
|
46
|
+
MIME = {'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
47
|
+
'.webp': 'image/webp', '.gif': 'image/gif', '.svg': 'image/svg+xml'}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def load_image_spec() -> dict:
|
|
51
|
+
try:
|
|
52
|
+
lc = json.loads(LC_PATH.read_text(encoding='utf-8'))
|
|
53
|
+
spec = lc.get('imageSpec') or {}
|
|
54
|
+
return spec if isinstance(spec, dict) else {}
|
|
55
|
+
except (OSError, json.JSONDecodeError):
|
|
56
|
+
return {}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
SPEC = load_image_spec()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def ratio_num(spec: str) -> float:
|
|
63
|
+
"""'3:1' → 3.0;非法返回 0。"""
|
|
64
|
+
m = re.match(r'\s*(\d+(?:\.\d+)?)\s*[:/×x]\s*(\d+(?:\.\d+)?)', str(spec or ''))
|
|
65
|
+
if not m:
|
|
66
|
+
return 0.0
|
|
67
|
+
a, b = float(m.group(1)), float(m.group(2))
|
|
68
|
+
return a / b if a > 0 and b > 0 else 0.0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def target_width(layout: str) -> int:
|
|
72
|
+
"""imageSpec.recommendedSizePx 形如 '2000×1125' → 2000。"""
|
|
73
|
+
raw = str((SPEC.get('recommendedSizePx') or {}).get(layout) or '2000×1125')
|
|
74
|
+
m = re.match(r'\s*(\d+)', raw)
|
|
75
|
+
return int(m.group(1)) if m else 2000
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def read_size(path: Path) -> tuple[int, int] | None:
|
|
79
|
+
"""纯标准库读位图尺寸(PNG/JPEG/GIF/WebP);SVG 走文本解析。"""
|
|
80
|
+
try:
|
|
81
|
+
data = path.read_bytes()[:65536]
|
|
82
|
+
except OSError:
|
|
83
|
+
return None
|
|
84
|
+
if len(data) < 16:
|
|
85
|
+
return None
|
|
86
|
+
# PNG
|
|
87
|
+
if data[:8] == b'\x89PNG\r\n\x1a\n':
|
|
88
|
+
return int.from_bytes(data[16:20], 'big'), int.from_bytes(data[20:24], 'big')
|
|
89
|
+
# GIF
|
|
90
|
+
if data[:6] in (b'GIF87a', b'GIF89a'):
|
|
91
|
+
return int.from_bytes(data[6:8], 'little'), int.from_bytes(data[8:10], 'little')
|
|
92
|
+
# JPEG:扫描 SOF 段
|
|
93
|
+
if data[:2] == b'\xff\xd8':
|
|
94
|
+
i = 2
|
|
95
|
+
while i + 9 < len(data):
|
|
96
|
+
if data[i] != 0xFF:
|
|
97
|
+
i += 1
|
|
98
|
+
continue
|
|
99
|
+
marker = data[i + 1]
|
|
100
|
+
if marker in (0xD8, 0x01) or 0xD0 <= marker <= 0xD7:
|
|
101
|
+
i += 2
|
|
102
|
+
continue
|
|
103
|
+
if marker == 0xD9:
|
|
104
|
+
break
|
|
105
|
+
seg_len = int.from_bytes(data[i + 2:i + 4], 'big')
|
|
106
|
+
if 0xC0 <= marker <= 0xCF and marker not in (0xC4, 0xC8, 0xCC):
|
|
107
|
+
h = int.from_bytes(data[i + 5:i + 7], 'big')
|
|
108
|
+
w = int.from_bytes(data[i + 7:i + 9], 'big')
|
|
109
|
+
return w, h
|
|
110
|
+
i += 2 + max(2, seg_len)
|
|
111
|
+
return None
|
|
112
|
+
# WebP
|
|
113
|
+
if data[:4] == b'RIFF' and data[8:12] == b'WEBP':
|
|
114
|
+
chunk = data[12:16]
|
|
115
|
+
if chunk == b'VP8X' and len(data) >= 30:
|
|
116
|
+
w = int.from_bytes(data[24:27], 'little') + 1
|
|
117
|
+
h = int.from_bytes(data[27:30], 'little') + 1
|
|
118
|
+
return w, h
|
|
119
|
+
if chunk == b'VP8 ' and len(data) >= 30:
|
|
120
|
+
w = int.from_bytes(data[26:28], 'little') & 0x3FFF
|
|
121
|
+
h = int.from_bytes(data[28:30], 'little') & 0x3FFF
|
|
122
|
+
return w, h
|
|
123
|
+
if chunk == b'VP8L' and len(data) >= 25:
|
|
124
|
+
bits = int.from_bytes(data[21:25], 'little')
|
|
125
|
+
return (bits & 0x3FFF) + 1, ((bits >> 14) & 0x3FFF) + 1
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def read_svg_size(path: Path) -> tuple[int, int] | None:
|
|
130
|
+
try:
|
|
131
|
+
head = path.read_text(encoding='utf-8', errors='replace')[:2048]
|
|
132
|
+
except OSError:
|
|
133
|
+
return None
|
|
134
|
+
w = re.search(r'width\s*=\s*["\']?\s*(\d+(?:\.\d+)?)', head)
|
|
135
|
+
h = re.search(r'height\s*=\s*["\']?\s*(\d+(?:\.\d+)?)', head)
|
|
136
|
+
if w and h:
|
|
137
|
+
return int(float(w.group(1))), int(float(h.group(1)))
|
|
138
|
+
vb = re.search(r'viewBox\s*=\s*["\']([\d.\-\s,]+)["\']', head)
|
|
139
|
+
if vb:
|
|
140
|
+
nums = re.split(r'[\s,]+', vb.group(1).strip())
|
|
141
|
+
if len(nums) == 4:
|
|
142
|
+
return int(float(nums[2])), int(float(nums[3]))
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def ratio_label(w: int, h: int) -> str:
|
|
147
|
+
if not w or not h:
|
|
148
|
+
return '未知'
|
|
149
|
+
from math import gcd
|
|
150
|
+
g = gcd(w, h) or 1
|
|
151
|
+
a, b = w // g, h // g
|
|
152
|
+
if a > 40 or b > 40:
|
|
153
|
+
return f'{w / h:.2f}:1'
|
|
154
|
+
return f'{a}:{b}'
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def try_resize(src: Path, dst: Path, want_w: int) -> bool:
|
|
158
|
+
"""有 Pillow 时缩放/压缩;无则返回 False(调用方回落原图)。"""
|
|
159
|
+
try:
|
|
160
|
+
from PIL import Image # type: ignore
|
|
161
|
+
except Exception:
|
|
162
|
+
return False
|
|
163
|
+
try:
|
|
164
|
+
with Image.open(src) as im:
|
|
165
|
+
im.load()
|
|
166
|
+
w, h = im.size
|
|
167
|
+
if w > want_w:
|
|
168
|
+
im = im.resize((want_w, max(1, round(h * want_w / w))), Image.LANCZOS)
|
|
169
|
+
if dst.suffix.lower() in ('.jpg', '.jpeg'):
|
|
170
|
+
if im.mode in ('RGBA', 'P', 'LA'):
|
|
171
|
+
bg = Image.new('RGB', im.size, (255, 255, 255))
|
|
172
|
+
bg.paste(im.convert('RGBA'), mask=im.convert('RGBA').split()[-1])
|
|
173
|
+
im = bg
|
|
174
|
+
else:
|
|
175
|
+
im = im.convert('RGB')
|
|
176
|
+
im.save(dst, 'JPEG', quality=82, optimize=True, progressive=True)
|
|
177
|
+
else:
|
|
178
|
+
im.save(dst, optimize=True)
|
|
179
|
+
return True
|
|
180
|
+
except Exception:
|
|
181
|
+
return False
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def collect_inputs(args_paths: list[str]) -> list[Path]:
|
|
185
|
+
out: list[Path] = []
|
|
186
|
+
for raw in args_paths:
|
|
187
|
+
p = Path(raw)
|
|
188
|
+
if p.is_dir():
|
|
189
|
+
out.extend(sorted(q for q in p.rglob('*') if q.suffix.lower() in IMAGE_EXT))
|
|
190
|
+
elif p.is_file():
|
|
191
|
+
out.append(p)
|
|
192
|
+
else:
|
|
193
|
+
print(f'[跳过] 不存在: {raw}')
|
|
194
|
+
seen, uniq = set(), []
|
|
195
|
+
for p in out:
|
|
196
|
+
if p.resolve() not in seen:
|
|
197
|
+
seen.add(p.resolve())
|
|
198
|
+
uniq.append(p)
|
|
199
|
+
return uniq
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def main() -> int:
|
|
203
|
+
ap = argparse.ArgumentParser(description='TopPPT HTML 用户图片素材准备')
|
|
204
|
+
ap.add_argument('paths', nargs='+', help='图片文件或目录(可多个)')
|
|
205
|
+
ap.add_argument('--layout', default='full', choices=list(SPEC.get('layouts') or
|
|
206
|
+
['full', 'half', 'bleed', 'grid', 'compare', 'wall']))
|
|
207
|
+
ap.add_argument('--out', default=None, help='输出目录(默认 ./report-assets)')
|
|
208
|
+
ap.add_argument('--mode', default='inline', choices=['inline', 'path'])
|
|
209
|
+
ap.add_argument('--max-bytes', type=int, default=int(SPEC.get('maxInlineBytes') or 1572864))
|
|
210
|
+
ap.add_argument('--json', action='store_true')
|
|
211
|
+
ap.add_argument('--no-resize', action='store_true')
|
|
212
|
+
args = ap.parse_args()
|
|
213
|
+
|
|
214
|
+
files = collect_inputs(args.paths)
|
|
215
|
+
if not files:
|
|
216
|
+
print('没有可处理的图片(支持 ' + '/'.join(sorted(e.lstrip(".") for e in IMAGE_EXT)) + ')。')
|
|
217
|
+
return 2
|
|
218
|
+
|
|
219
|
+
out_dir = Path(args.out) if args.out else Path.cwd() / 'report-assets'
|
|
220
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
221
|
+
assets_dir = out_dir / 'assets'
|
|
222
|
+
want_w = target_width(args.layout)
|
|
223
|
+
max_total = int(SPEC.get('maxTotalInlineBytes') or 8388608)
|
|
224
|
+
|
|
225
|
+
rows, html_bits, model_items, notes = [], [], [], []
|
|
226
|
+
total_inline = 0
|
|
227
|
+
for src in files:
|
|
228
|
+
ext = src.suffix.lower()
|
|
229
|
+
size = read_svg_size(src) if ext == '.svg' else read_size(src)
|
|
230
|
+
w, h = size if size else (0, 0)
|
|
231
|
+
work = src
|
|
232
|
+
resized = False
|
|
233
|
+
if not args.no_resize and w > want_w and ext != '.svg':
|
|
234
|
+
cand = out_dir / (src.stem + f'-{want_w}' + ('.jpg' if ext in ('.jpg', '.jpeg') else ext))
|
|
235
|
+
if try_resize(src, cand, want_w):
|
|
236
|
+
work, resized = cand, True
|
|
237
|
+
size = read_size(cand)
|
|
238
|
+
w, h = size if size else (w, h)
|
|
239
|
+
nbytes = work.stat().st_size
|
|
240
|
+
if not resized and w and w < want_w * 0.75:
|
|
241
|
+
notes.append(f'{src.name}: 宽 {w}px < 建议 {want_w}px(放大易糊;请换更高分辨率原图或改小版式)')
|
|
242
|
+
want_ratio = ratio_num((SPEC.get('ratioDefault') or {}).get(args.layout) or '')
|
|
243
|
+
fit_cls = ''
|
|
244
|
+
if w and h and want_ratio:
|
|
245
|
+
got = w / h
|
|
246
|
+
if abs(got - want_ratio) / want_ratio > 0.25:
|
|
247
|
+
fit_cls = ' media--contain' # 比例差得多 → HTML 也用 contain,与模型 fit 保持一致
|
|
248
|
+
notes.append(f'{src.name}: 实际比例 {got:.2f}:1 与 {args.layout} 版式锁定比例 '
|
|
249
|
+
f'{want_ratio:.2f}:1 相差较大 → 已按 fit:"contain" 输出(完整显示留白);'
|
|
250
|
+
f'若希望裁切填满请换更贴合比例的版式/素材')
|
|
251
|
+
if ext == '.svg':
|
|
252
|
+
notes.append(f'{src.name}: SVG 为矢量(好);注意外链字体/图片需内联,否则破坏零外链铁律')
|
|
253
|
+
|
|
254
|
+
use_path = args.mode == 'path' or nbytes > args.max_bytes
|
|
255
|
+
if use_path:
|
|
256
|
+
assets_dir.mkdir(parents=True, exist_ok=True)
|
|
257
|
+
dst = assets_dir / work.name
|
|
258
|
+
if work.resolve() != dst.resolve():
|
|
259
|
+
shutil.copy2(work, dst)
|
|
260
|
+
url = f'assets/{dst.name}'
|
|
261
|
+
form = '相对路径'
|
|
262
|
+
else:
|
|
263
|
+
b64 = base64.b64encode(work.read_bytes()).decode('ascii')
|
|
264
|
+
url = f'data:{MIME.get(ext, "image/png")};base64,{b64}'
|
|
265
|
+
form = 'data: 内联'
|
|
266
|
+
total_inline += len(url)
|
|
267
|
+
if nbytes > args.max_bytes and args.mode != 'path':
|
|
268
|
+
notes.append(f'{src.name}: {nbytes // 1024}KB > 内联上限 {args.max_bytes // 1024}KB → 已改用相对路径')
|
|
269
|
+
|
|
270
|
+
rows.append({'file': src.name, 'w': w, 'h': h, 'ratio': ratio_label(w, h),
|
|
271
|
+
'kb': nbytes // 1024, 'form': form, 'url': url,
|
|
272
|
+
'targetW': want_w, 'resized': resized})
|
|
273
|
+
lock_cls = (SPEC.get('ratioCssClass') or {}).get(args.layout) or f'media--r{ratio_class(w, h)}'
|
|
274
|
+
html_bits.append(
|
|
275
|
+
f'<figure class="media {lock_cls}{fit_cls} rv">\n'
|
|
276
|
+
f' <img src="{url if len(url) < 400 else url[:60] + "…(见 images-snippets.html 全文)"}" '
|
|
277
|
+
f'alt="{src.stem}">\n'
|
|
278
|
+
f' <figcaption class="media__cap--below">图:{src.stem}({w}×{h} · {ratio_label(w, h)})</figcaption>\n'
|
|
279
|
+
f'</figure>')
|
|
280
|
+
model_items.append({'src': url, 'alt': src.stem, 'caption': f'图:{src.stem}'})
|
|
281
|
+
|
|
282
|
+
if total_inline > max_total:
|
|
283
|
+
notes.append(f'内联总量 {total_inline // 1024}KB > 上限 {max_total // 1024}KB:'
|
|
284
|
+
'建议部分图片改用 --mode path(相对路径)')
|
|
285
|
+
|
|
286
|
+
layout = args.layout
|
|
287
|
+
if len(model_items) > 1 and layout not in (SPEC.get('multiLayouts') or ['grid', 'compare', 'wall']):
|
|
288
|
+
notes.append(f'共 {len(model_items)} 张图但 layout={layout}(单图版式)→ 多图请用 grid/compare/wall')
|
|
289
|
+
want_ratio = ratio_num((SPEC.get('ratioDefault') or {}).get(layout) or '')
|
|
290
|
+
fit = SPEC.get('fitDefault') or 'cover'
|
|
291
|
+
if want_ratio and any(r['w'] and r['h'] and abs(r['w'] / r['h'] - want_ratio) / want_ratio > 0.25
|
|
292
|
+
for r in rows):
|
|
293
|
+
fit = 'contain' # 比例差得多时默认完整显示,避免 cover 切掉关键内容
|
|
294
|
+
image_model = {'layout': layout, 'fit': fit,
|
|
295
|
+
'caption': f'图:{model_items[0]["alt"]}' if model_items else ''}
|
|
296
|
+
if len(model_items) == 1:
|
|
297
|
+
image_model['src'] = model_items[0]['src']
|
|
298
|
+
image_model['alt'] = model_items[0]['alt']
|
|
299
|
+
else:
|
|
300
|
+
image_model['items'] = model_items
|
|
301
|
+
|
|
302
|
+
(out_dir / 'images-snippets.html').write_text('\n\n'.join(html_bits), encoding='utf-8')
|
|
303
|
+
(out_dir / 'images-model.json').write_text(
|
|
304
|
+
json.dumps(image_model, ensure_ascii=False, indent=2), encoding='utf-8')
|
|
305
|
+
|
|
306
|
+
if args.json:
|
|
307
|
+
print(json.dumps({'layout': layout, 'out': str(out_dir), 'images': rows, 'notes': notes},
|
|
308
|
+
ensure_ascii=False, indent=2))
|
|
309
|
+
return 0
|
|
310
|
+
|
|
311
|
+
print(f'图片素材准备 · layout={layout} · 建议宽 {want_w}px · 共 {len(rows)} 张')
|
|
312
|
+
print(f'{"文件":<28}{"尺寸":<14}{"比例":<10}{"体积":<10}形态')
|
|
313
|
+
for r in rows:
|
|
314
|
+
dim = f'{r["w"]}×{r["h"]}' if r['w'] else '未知'
|
|
315
|
+
print(f'{r["file"][:26]:<28}{dim:<14}{r["ratio"]:<10}{str(r["kb"]) + "KB":<10}{r["form"]}'
|
|
316
|
+
+ ('(已缩放)' if r['resized'] else ''))
|
|
317
|
+
if notes:
|
|
318
|
+
print('\n提示:')
|
|
319
|
+
for n in notes:
|
|
320
|
+
print(' · ' + n)
|
|
321
|
+
print(f'\n已写出:\n {out_dir / "images-snippets.html"} (HTML .media 片段,粘进对应 section.band)')
|
|
322
|
+
print(f' {out_dir / "images-model.json"} (REPORT_MODEL 的 image 对象,并入 sections[])')
|
|
323
|
+
if args.mode == 'inline':
|
|
324
|
+
print(' 说明: 内联形态保持「单文件零外链」;体积大时改用 --mode path 并把 assets/ 目录随报告一起交付。')
|
|
325
|
+
print(' 占位: 暂无图时先在模型写 image.placeholder=true(正文用 .media--ph),交付前替换 src 即可。')
|
|
326
|
+
return 0
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def ratio_class(w: int, h: int) -> str:
|
|
330
|
+
"""把实际比例归一到 .media--r* 锁定类(16-9 / 4-3 / 3-2 / 1-1 / 21-9)。"""
|
|
331
|
+
if not w or not h:
|
|
332
|
+
return '16-9'
|
|
333
|
+
r = w / h
|
|
334
|
+
for name, val in (('21-9', 21 / 9), ('16-9', 16 / 9), ('3-2', 1.5), ('4-3', 4 / 3), ('1-1', 1.0)):
|
|
335
|
+
if abs(r - val) / val <= 0.06:
|
|
336
|
+
return name
|
|
337
|
+
return '16-9' if r > 1.4 else ('1-1' if r > 0.9 else '4-3')
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
if __name__ == '__main__':
|
|
341
|
+
sys.exit(main())
|