@lark-apaas/coding-steering 0.1.31 → 0.1.32-beta.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/package.json +1 -1
- package/steering/design-html/skills/pptx-style-extract/SKILL.md +112 -0
- package/steering/design-html/skills/pptx-style-extract/font-fallback.yaml +129 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/census.py +955 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/check_v2.py +907 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/draft.py +945 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/export_consumer_md.py +75 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/export_consumer_zip.py +175 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/extract.py +765 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/ooxml.py +699 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/package.py +1120 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/parts.py +461 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/query.py +562 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/render_pages.py +679 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/verify_font.py +68 -0
- package/steering/design-html/skills/pptx-style-extract/v2-format-spec.md +193 -0
|
@@ -0,0 +1,945 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""S15 草案生成器:把阶段一产物机械推导成「判断单草案 + 一页简报 + 候选联系表」。
|
|
3
|
+
|
|
4
|
+
python3 draft.py <stage1-outdir>
|
|
5
|
+
|
|
6
|
+
产出 <stage1-outdir>/l-out/:
|
|
7
|
+
BRIEF.md 唯一必读简报:事实 + 草案依据 + 待判断清单
|
|
8
|
+
contact-sheet.png 候选图拼版(带编号,一次看完所有图)
|
|
9
|
+
manifest.yaml / frontmatter.yaml / layouts.yaml / body.md 四件草案,可直接进 package.py
|
|
10
|
+
|
|
11
|
+
草案里所有数值都来自 extract.json;凡是需要「像人一样看」才能定的,写成 `TODO:` 行
|
|
12
|
+
(package.py 见 TODO 即 FAIL),由 L 层改掉。
|
|
13
|
+
"""
|
|
14
|
+
import argparse
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import shutil
|
|
19
|
+
import sys
|
|
20
|
+
from collections import Counter, defaultdict
|
|
21
|
+
|
|
22
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
23
|
+
SKILL_ROOT = os.path.dirname(HERE)
|
|
24
|
+
SYS_FALLBACK = '"PingFang SC", "Microsoft YaHei", sans-serif'
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ---------------------------------------------------------------- 小工具
|
|
28
|
+
def hex2rgb(h):
|
|
29
|
+
h = h.lstrip('#')
|
|
30
|
+
return tuple(int(h[i:i + 2], 16) for i in (0, 2, 4))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def lum(rgb):
|
|
34
|
+
return (0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]) / 255.0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def satu(rgb):
|
|
38
|
+
mx, mn = max(rgb), min(rgb)
|
|
39
|
+
return 0.0 if mx == 0 else (mx - mn) / mx
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def slide_no(part):
|
|
43
|
+
m = re.search(r'slide(\d+)\.xml$', part)
|
|
44
|
+
return int(m.group(1)) if m else 9999
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def walk_sz(node, out):
|
|
48
|
+
if isinstance(node, dict):
|
|
49
|
+
if 'sz_px' in node and isinstance(node['sz_px'], (int, float)):
|
|
50
|
+
out.append(node['sz_px'])
|
|
51
|
+
for v in node.values():
|
|
52
|
+
walk_sz(v, out)
|
|
53
|
+
elif isinstance(node, list):
|
|
54
|
+
for v in node:
|
|
55
|
+
walk_sz(v, out)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def shape_sz(s):
|
|
59
|
+
out = []
|
|
60
|
+
walk_sz(s.get('text') or {}, out)
|
|
61
|
+
walk_sz(s.get('lstStyle') or {}, out)
|
|
62
|
+
return max(out) if out else 0.0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def shape_text(s, limit=24):
|
|
66
|
+
buf = []
|
|
67
|
+
for p in (s.get('text') or {}).get('paragraphs', []):
|
|
68
|
+
for r in p.get('runs', []):
|
|
69
|
+
t = (r.get('text') or '').strip()
|
|
70
|
+
if t:
|
|
71
|
+
buf.append(t)
|
|
72
|
+
txt = ' '.join(buf).replace('\n', ' ')
|
|
73
|
+
return txt[:limit]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def q(v):
|
|
77
|
+
"""写进 YAML 的标量:需要引号的加引号。"""
|
|
78
|
+
s = str(v)
|
|
79
|
+
if s and (s[0] in '#{[&*!|>%@`"\'' or ': ' in s or s.strip() != s):
|
|
80
|
+
return '"%s"' % s.replace('"', '\\"')
|
|
81
|
+
return s
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ---------------------------------------------------------------- 颜色
|
|
85
|
+
def draft_colors(d):
|
|
86
|
+
pool = [c for c in d['color_freq']
|
|
87
|
+
if c.get('class') == 'design' and abs((c.get('alpha') or 100) - 100) < 0.1]
|
|
88
|
+
seen, rows = set(), []
|
|
89
|
+
for c in pool:
|
|
90
|
+
h = c['hex'].upper()
|
|
91
|
+
if h in seen:
|
|
92
|
+
continue
|
|
93
|
+
seen.add(h)
|
|
94
|
+
rgb = hex2rgb(h)
|
|
95
|
+
rows.append({'hex': h, 'n': c['n'], 'lum': lum(rgb), 'sat': satu(rgb)})
|
|
96
|
+
rows.sort(key=lambda r: -r['n'])
|
|
97
|
+
|
|
98
|
+
tokens, used = [], set()
|
|
99
|
+
|
|
100
|
+
def take(pred, names):
|
|
101
|
+
for name in names:
|
|
102
|
+
for r in rows:
|
|
103
|
+
if r['hex'] in used or not pred(r):
|
|
104
|
+
continue
|
|
105
|
+
used.add(r['hex'])
|
|
106
|
+
tokens.append((name, r))
|
|
107
|
+
break
|
|
108
|
+
|
|
109
|
+
take(lambda r: r['lum'] > 0.85 and r['sat'] < 0.15, ['surface', 'surface-alt'])
|
|
110
|
+
take(lambda r: r['lum'] < 0.32 and r['sat'] < 0.25, ['ink', 'ink-muted'])
|
|
111
|
+
take(lambda r: r['sat'] >= 0.35, ['primary', 'accent', 'accent-2', 'accent-3'])
|
|
112
|
+
take(lambda r: r['sat'] < 0.35, ['neutral'])
|
|
113
|
+
# 未取用但高频的留给 BRIEF 展示
|
|
114
|
+
rest = [r for r in rows if r['hex'] not in used][:6]
|
|
115
|
+
return tokens, rest, rows
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# ---------------------------------------------------------------- 字体
|
|
119
|
+
def parse_fallback_table():
|
|
120
|
+
path = os.path.join(SKILL_ROOT, 'font-fallback.yaml')
|
|
121
|
+
if not os.path.exists(path):
|
|
122
|
+
return []
|
|
123
|
+
fams, cur = [], None
|
|
124
|
+
for line in open(path, encoding='utf-8'):
|
|
125
|
+
m = re.match(r'\s*-\s*family:\s*(.+)', line)
|
|
126
|
+
if m:
|
|
127
|
+
cur = {'family': m.group(1).strip(), 'match': [], 'fallback': [], 'category': ''}
|
|
128
|
+
fams.append(cur)
|
|
129
|
+
continue
|
|
130
|
+
if cur is None:
|
|
131
|
+
continue
|
|
132
|
+
m = re.match(r'\s*(match|fallback):\s*\[(.*)\]', line)
|
|
133
|
+
if m:
|
|
134
|
+
cur[m.group(1)] = [x.strip().strip('"\'') for x in m.group(2).split(',') if x.strip()]
|
|
135
|
+
m = re.match(r'\s*category:\s*(.+)', line)
|
|
136
|
+
if m:
|
|
137
|
+
cur['category'] = m.group(1).strip()
|
|
138
|
+
return fams
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def norm(s):
|
|
142
|
+
return re.sub(r'[\s\-_]', '', s or '').lower()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def draft_fonts(d):
|
|
146
|
+
table = parse_fallback_table()
|
|
147
|
+
groups = defaultdict(lambda: {'rendered': 0, 'weights': set(), 'names': set(), 'bold': 0})
|
|
148
|
+
for f in d['font_families']:
|
|
149
|
+
if not f.get('rendered_n'):
|
|
150
|
+
continue
|
|
151
|
+
key = f.get('alias_group') or f['family']
|
|
152
|
+
g = groups[key]
|
|
153
|
+
g['rendered'] += f['rendered_n']
|
|
154
|
+
g['names'].add(f['family'])
|
|
155
|
+
g['bold'] += f.get('bold_runs') or 0
|
|
156
|
+
for v in f.get('variants', []):
|
|
157
|
+
if v.get('weight'):
|
|
158
|
+
g['weights'].add(v['weight'])
|
|
159
|
+
ranked = sorted(groups.items(), key=lambda kv: -kv[1]['rendered'])
|
|
160
|
+
|
|
161
|
+
def resolve(names):
|
|
162
|
+
for n in names:
|
|
163
|
+
for fam in table:
|
|
164
|
+
for m in fam['match']:
|
|
165
|
+
if norm(m) == norm(n) or norm(m) in norm(n) or norm(n) in norm(m):
|
|
166
|
+
return fam
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
out = []
|
|
170
|
+
for key, g in ranked[:4]:
|
|
171
|
+
fam = resolve(sorted(g['names'], key=len))
|
|
172
|
+
stack = [sorted(g['names'], key=len)[0]]
|
|
173
|
+
if fam:
|
|
174
|
+
stack += [x for x in fam['fallback'] if x not in stack]
|
|
175
|
+
out.append({
|
|
176
|
+
'key': key, 'rendered': g['rendered'], 'names': sorted(g['names']),
|
|
177
|
+
'weights': sorted(g['weights']) or ([600] if g['bold'] else [400]),
|
|
178
|
+
'stack': stack, 'mapped': fam['family'] if fam else None,
|
|
179
|
+
'category': fam['category'] if fam else '',
|
|
180
|
+
})
|
|
181
|
+
return out
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def font_css(stack):
|
|
185
|
+
return ', '.join('"%s"' % s for s in stack) + ', ' + SYS_FALLBACK
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
MIRROR = 'https://miaoda.feishu.cn/fonts/css2'
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def import_line(fonts):
|
|
192
|
+
"""降级链里用到的镜像字体拼成一行 @import(check_v1 硬要求)。"""
|
|
193
|
+
webs = []
|
|
194
|
+
for f in fonts[:2]:
|
|
195
|
+
for name in f['stack'][1:]:
|
|
196
|
+
if name not in webs:
|
|
197
|
+
webs.append(name)
|
|
198
|
+
if not webs:
|
|
199
|
+
webs = ['Noto Sans SC']
|
|
200
|
+
fam = '&'.join('family=%s:wght@400' % w.replace(' ', '+') for w in webs)
|
|
201
|
+
return "@import url('%s?%s&display=swap');" % (MIRROR, fam), webs
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def draft_anchors(d, tokens, fonts, roles, assets, archetypes):
|
|
205
|
+
names = [t[0] for t in tokens]
|
|
206
|
+
chroma = [n for n in names if n.startswith(('primary', 'accent'))]
|
|
207
|
+
radii = d.get('radii_census') or []
|
|
208
|
+
zero = next((r for r in radii if r['px'] == 0), None)
|
|
209
|
+
total_r = sum(r['n'] for r in radii) or 1
|
|
210
|
+
geom = d.get('geom_census') or {}
|
|
211
|
+
eff = d.get('effects_census') or {}
|
|
212
|
+
A = []
|
|
213
|
+
if chroma:
|
|
214
|
+
A.append((chroma[0] + '-led-palette', 'token',
|
|
215
|
+
'表达色只有 %s 这一组,其余全是中性底与墨色,见 colors' % '、'.join(chroma[:3])))
|
|
216
|
+
if zero and zero['n'] >= total_r * 0.7:
|
|
217
|
+
A.append(('zero-radius', 'token', '卡片、按钮、面板一律直角,圆角量在全 deck 压倒性为零'))
|
|
218
|
+
if any(a['kind'] == 'background' for a in assets):
|
|
219
|
+
A.append(('full-bleed-ground', 'pattern', '每页由整幅铺满的底图打底,元素浮在图上而不是浮在纯色块上'))
|
|
220
|
+
if any(a['kind'] == 'logo' for a in assets):
|
|
221
|
+
A.append(('corner-locked-logo', 'component', '品牌标识固定在同一角位,跨页不移动、不缩放'))
|
|
222
|
+
if geom.get('gradient_fills'):
|
|
223
|
+
A.append(('gradient-accent', 'pattern', '强调元素靠线性渐变承载,而不是纯色块'))
|
|
224
|
+
if len(fonts) >= 2:
|
|
225
|
+
A.append(('dual-family-typesetting', 'token', '中文与拉丁数字分属两套字族,同一行里混排'))
|
|
226
|
+
if eff.get('outerShdw'):
|
|
227
|
+
A.append(('soft-shadow-card', 'component', '卡片靠极浅外阴影托起,不用描边分隔'))
|
|
228
|
+
disp, body = roles.get('display'), roles.get('body')
|
|
229
|
+
if disp and body and disp['sz_px'] >= body['sz_px'] * 3:
|
|
230
|
+
A.append(('size-driven-hierarchy', 'pattern', '层级靠字号跨度拉开而不是字重,展示档与正文档差出数倍'))
|
|
231
|
+
A.append(('archetype-reuse', 'pattern', '全 deck 只用 %d 种页型反复排列,版面骨架高度复用' % len(archetypes)))
|
|
232
|
+
A.append(('safe-area-discipline', 'token', '正文一律落在统一安全区内,不贴画布边'))
|
|
233
|
+
seen, out = set(), []
|
|
234
|
+
for a in A:
|
|
235
|
+
if a[0] in seen:
|
|
236
|
+
continue
|
|
237
|
+
seen.add(a[0])
|
|
238
|
+
out.append(a)
|
|
239
|
+
return out[:8]
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# ---------------------------------------------------------------- 字号轴
|
|
243
|
+
def draft_scale(d, archetypes=()):
|
|
244
|
+
ts = [t for t in d['text_scale'] if t['sz_px'] >= 10]
|
|
245
|
+
ts.sort(key=lambda t: -t['sz_px'])
|
|
246
|
+
if not ts:
|
|
247
|
+
return {}
|
|
248
|
+
by_px = {t['sz_px']: t for t in ts}
|
|
249
|
+
# display 优先取「真的当标题用过」的字号(archetype 首槽),而不是全局最大值
|
|
250
|
+
title_sz = Counter(s['sz'] for a in archetypes for s in a['slots'] if s['type'] == 'title')
|
|
251
|
+
display = by_px.get(max(title_sz)) if title_sz else None
|
|
252
|
+
big = [t for t in ts if t['n'] >= 2] or ts
|
|
253
|
+
display = display or big[0]
|
|
254
|
+
body_pool = [t for t in ts if t['sz_px'] <= 48]
|
|
255
|
+
body = max(body_pool, key=lambda t: t['n']) if body_pool else ts[-1]
|
|
256
|
+
heading_pool = [t for t in ts if body['sz_px'] * 1.3 <= t['sz_px'] < display['sz_px']]
|
|
257
|
+
heading = max(heading_pool, key=lambda t: t['n']) if heading_pool else None
|
|
258
|
+
small_pool = [t for t in ts if t['sz_px'] < body['sz_px']]
|
|
259
|
+
caption = max(small_pool, key=lambda t: t['n']) if small_pool else None
|
|
260
|
+
roles = {'display': display, 'body': body}
|
|
261
|
+
if heading:
|
|
262
|
+
roles['heading'] = heading
|
|
263
|
+
if caption:
|
|
264
|
+
roles['caption'] = caption
|
|
265
|
+
return roles
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def lh_of(t):
|
|
269
|
+
lhm = t.get('line_height_mult') or {}
|
|
270
|
+
if not lhm:
|
|
271
|
+
return None
|
|
272
|
+
best = max(lhm.items(), key=lambda kv: kv[1])[0]
|
|
273
|
+
try:
|
|
274
|
+
v = float(best)
|
|
275
|
+
except ValueError:
|
|
276
|
+
return None
|
|
277
|
+
return v if 0.9 <= v <= 2.2 else None
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# ---------------------------------------------------------------- 资产
|
|
281
|
+
def probe_image(path):
|
|
282
|
+
info = {'w': None, 'h': None, 'alpha_mean': None, 'near_blank': False}
|
|
283
|
+
try:
|
|
284
|
+
from PIL import Image
|
|
285
|
+
except Exception:
|
|
286
|
+
return info
|
|
287
|
+
try:
|
|
288
|
+
im = Image.open(path)
|
|
289
|
+
info['w'], info['h'] = im.size
|
|
290
|
+
if im.mode in ('RGBA', 'LA') or 'transparency' in im.info:
|
|
291
|
+
px = im.convert('RGBA').getchannel('A').resize((64, 64)).tobytes()
|
|
292
|
+
info['alpha_mean'] = sum(px) / len(px)
|
|
293
|
+
info['near_blank'] = info['alpha_mean'] < 13 # <5% 不透明度
|
|
294
|
+
except Exception:
|
|
295
|
+
pass
|
|
296
|
+
return info
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def copy_logo_candidates(outdir, logo_pool):
|
|
300
|
+
if not logo_pool:
|
|
301
|
+
return []
|
|
302
|
+
dst_dir = os.path.join(outdir, 'ref', 'logo-candidates')
|
|
303
|
+
os.makedirs(dst_dir, exist_ok=True)
|
|
304
|
+
rows = []
|
|
305
|
+
for rank, (score, c) in enumerate(sorted(logo_pool, key=lambda kv: (-kv[0], -kv[1]['n'])), 1):
|
|
306
|
+
src = os.path.join(outdir, c.get('out') or '')
|
|
307
|
+
if not os.path.exists(src):
|
|
308
|
+
continue
|
|
309
|
+
name = '%02d-score%s-%s' % (rank, score, c['file'])
|
|
310
|
+
dst = os.path.join(dst_dir, name)
|
|
311
|
+
shutil.copy2(src, dst)
|
|
312
|
+
b = c.get('box') or {}
|
|
313
|
+
rows.append({
|
|
314
|
+
'rank': rank,
|
|
315
|
+
'score': score,
|
|
316
|
+
'file': c['file'],
|
|
317
|
+
'copy': os.path.relpath(dst, outdir),
|
|
318
|
+
'slides': c.get('slides') or [],
|
|
319
|
+
'box': [round(b.get(k, 0)) for k in ('x', 'y', 'w', 'h')],
|
|
320
|
+
'used_n': c.get('n', 0),
|
|
321
|
+
})
|
|
322
|
+
if rows:
|
|
323
|
+
with open(os.path.join(dst_dir, 'index.json'), 'w', encoding='utf-8') as f:
|
|
324
|
+
json.dump(rows, f, ensure_ascii=False, indent=2)
|
|
325
|
+
f.write('\n')
|
|
326
|
+
return rows
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def draft_assets(d, outdir, bg_needed=(), cover_media=None, bg_under=None):
|
|
330
|
+
imgs = {i['media']: i for i in d['images']}
|
|
331
|
+
cluster_of = {}
|
|
332
|
+
for c in d.get('media_clusters', []):
|
|
333
|
+
for m in c['members']:
|
|
334
|
+
cluster_of[m] = c['content_id']
|
|
335
|
+
|
|
336
|
+
cands = []
|
|
337
|
+
for m in d['media']:
|
|
338
|
+
if not m.get('exported'):
|
|
339
|
+
continue
|
|
340
|
+
img = imgs.get(m['media'], {})
|
|
341
|
+
out_rel = m.get('out') or ''
|
|
342
|
+
probe = probe_image(os.path.join(outdir, out_rel)) if out_rel else {}
|
|
343
|
+
boxes = img.get('boxes') or []
|
|
344
|
+
top = max(boxes, key=lambda b: b.get('count', 0)) if boxes else {}
|
|
345
|
+
parts = top.get('parts') or []
|
|
346
|
+
slides = sorted({slide_no(p) for p in parts if '/slides/' in p})
|
|
347
|
+
cands.append({
|
|
348
|
+
'media': m['media'], 'file': os.path.basename(out_rel), 'out': out_rel,
|
|
349
|
+
'bytes': m.get('bytes'), 'n': img.get('n', m.get('used_n', 0)),
|
|
350
|
+
'fullscreen': bool(img.get('fullscreen')), 'w_pct': img.get('max_w_pct', 0),
|
|
351
|
+
'box': top.get('box') or {}, 'slides': slides,
|
|
352
|
+
'layer_only': bool(parts) and not slides,
|
|
353
|
+
'repeat': bool(img.get('repeat_fixed')),
|
|
354
|
+
'cluster': cluster_of.get(m['media']),
|
|
355
|
+
'probe': probe, 'reasons': m.get('reasons', []),
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
# 同素材簇去重:留 n 最大的一张
|
|
359
|
+
best_of = {}
|
|
360
|
+
for c in cands:
|
|
361
|
+
k = c['cluster'] or c['media']
|
|
362
|
+
if k not in best_of or c['n'] > best_of[k]['n']:
|
|
363
|
+
best_of[k] = c
|
|
364
|
+
kept = sorted(best_of.values(), key=lambda c: (-c['n'], -(c['bytes'] or 0)))
|
|
365
|
+
|
|
366
|
+
assets, rejected, todos = [], [], []
|
|
367
|
+
logo_pool = []
|
|
368
|
+
bg_under = bg_under or {}
|
|
369
|
+
bg_i = 0
|
|
370
|
+
canvas_w = d['canvas']['px'][0]
|
|
371
|
+
for c in kept:
|
|
372
|
+
if c['probe'].get('near_blank'):
|
|
373
|
+
rejected.append((c, '近全透明(alpha 均值 %.0f/255),PPT 里看不见' % c['probe']['alpha_mean']))
|
|
374
|
+
continue
|
|
375
|
+
if c['fullscreen']:
|
|
376
|
+
if c['media'] == cover_media:
|
|
377
|
+
assets.append({'id': 'bg-cover', 'kind': 'background', 'role': 'cover',
|
|
378
|
+
'src': c, 'use_full': True})
|
|
379
|
+
elif c['media'] in bg_needed and bg_i < 5:
|
|
380
|
+
bg_i += 1
|
|
381
|
+
assets.append({'id': 'bg-content-%d' % bg_i, 'kind': 'background',
|
|
382
|
+
'role': 'content', 'src': c, 'use_full': False})
|
|
383
|
+
else:
|
|
384
|
+
rejected.append((c, '满屏图但没有页面以它为主底(只在版式层备用)'))
|
|
385
|
+
elif c['w_pct'] < 30 and c['n'] >= 2:
|
|
386
|
+
b = c['box']
|
|
387
|
+
score = 0
|
|
388
|
+
score += 3 if b.get('y', 999) < 160 else (1 if b.get('y', 0) > canvas_w * 0.5 else 0)
|
|
389
|
+
score += 2 if b.get('x', 999) < 200 or b.get('x', 0) > canvas_w * 0.7 else 0
|
|
390
|
+
ar = (b.get('w') or 1) / max(b.get('h') or 1, 1)
|
|
391
|
+
score += 1 if 1.0 <= ar <= 8.0 else 0
|
|
392
|
+
score += 1 if c['n'] >= 2 else 0
|
|
393
|
+
logo_pool.append((score, c))
|
|
394
|
+
else:
|
|
395
|
+
rejected.append((c, '内容区图片(占宽 %.0f%%,出现 %d 次)' % (c['w_pct'], c['n'])))
|
|
396
|
+
|
|
397
|
+
def on_bg_of(c):
|
|
398
|
+
"""logo 压在浅底还是深底:直接采底图上它那块区域的亮度,不用人判。"""
|
|
399
|
+
bg = bg_under.get(c['slides'][0]) if c['slides'] else None
|
|
400
|
+
row = next((m for m in d['media'] if m['media'] == bg and m.get('out')), None)
|
|
401
|
+
if not row:
|
|
402
|
+
return None
|
|
403
|
+
try:
|
|
404
|
+
from PIL import Image
|
|
405
|
+
im = Image.open(os.path.join(outdir, row['out'])).convert('RGB')
|
|
406
|
+
b = c['box']
|
|
407
|
+
sx, sy = im.width / float(canvas_w), im.height / float(d['canvas']['px'][1])
|
|
408
|
+
crop = im.crop((int(b.get('x', 0) * sx), int(b.get('y', 0) * sy),
|
|
409
|
+
max(int((b.get('x', 0) + b.get('w', 1)) * sx), 1),
|
|
410
|
+
max(int((b.get('y', 0) + b.get('h', 1)) * sy), 1))).resize((16, 16))
|
|
411
|
+
raw = crop.tobytes()
|
|
412
|
+
px = [raw[i:i + 3] for i in range(0, len(raw), 3)]
|
|
413
|
+
return 'light' if sum(lum(p) for p in px) / len(px) > 0.55 else 'dark'
|
|
414
|
+
except Exception:
|
|
415
|
+
return None
|
|
416
|
+
|
|
417
|
+
logo_pool.sort(key=lambda kv: (-kv[0], -kv[1]['n']))
|
|
418
|
+
for i, (score, c) in enumerate(logo_pool):
|
|
419
|
+
b = c['box']
|
|
420
|
+
if i == 0 and score >= 5:
|
|
421
|
+
assets.append({'id': 'logo-primary', 'kind': 'logo', 'role': None, 'src': c,
|
|
422
|
+
'use_full': False, 'on_bg': on_bg_of(c)})
|
|
423
|
+
todos.append('看联系表确认 `%s`(%.0fx%.0f @ %.0f,%.0f,出现 %d 次)真是品牌 logo;'
|
|
424
|
+
'不是就把 manifest 的 logo-primary 换成别的候选或整条删掉'
|
|
425
|
+
% (c['file'], b.get('w', 0), b.get('h', 0), b.get('x', 0), b.get('y', 0), c['n']))
|
|
426
|
+
else:
|
|
427
|
+
rejected.append((c, '重复小图(%.0fx%.0f @ %.0f,%.0f),logo 相似度低于首选'
|
|
428
|
+
% (b.get('w', 0), b.get('h', 0), b.get('x', 0), b.get('y', 0))))
|
|
429
|
+
|
|
430
|
+
if not any(a['role'] == 'cover' for a in assets):
|
|
431
|
+
todos.append('没定出封面底图——从联系表挑一张补进 manifest(role: cover),或在 gaps 写明模板无封面主视觉')
|
|
432
|
+
copy_logo_candidates(outdir, logo_pool)
|
|
433
|
+
return assets, rejected, todos
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
# ---------------------------------------------------------------- 版式聚类
|
|
437
|
+
DECOR_MIN = 40.0
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def draft_layouts(d, outdir):
|
|
441
|
+
shapes = json.load(open(os.path.join(outdir, 'ref', 'shapes.json'), encoding='utf-8'))['shapes']
|
|
442
|
+
cW, cH = d['canvas']['px']
|
|
443
|
+
by_slide = defaultdict(list)
|
|
444
|
+
for s in shapes:
|
|
445
|
+
if s.get('layer') == 'slide':
|
|
446
|
+
by_slide[s['part']].append(s)
|
|
447
|
+
|
|
448
|
+
bg_of_slide, layout_of_slide = {}, {}
|
|
449
|
+
for s in d.get('slides', []):
|
|
450
|
+
bg = s.get('background')
|
|
451
|
+
bg_of_slide[s['part']] = json.dumps(bg, sort_keys=True) if isinstance(bg, dict) else bg
|
|
452
|
+
layout_of_slide[s['part']] = s.get('layout')
|
|
453
|
+
# 版式层的满屏底图(form=2 常态:底图挂在 layout 上)
|
|
454
|
+
bg_of_layout = {}
|
|
455
|
+
for s in shapes:
|
|
456
|
+
if (s.get('layer') == 'layout' and s.get('kind') == 'pic'
|
|
457
|
+
and s.get('w_pct', 0) >= 95 and s.get('h_pct', 0) >= 95):
|
|
458
|
+
bg_of_layout.setdefault(s['part'], s.get('media'))
|
|
459
|
+
|
|
460
|
+
pages = []
|
|
461
|
+
for part, sh in sorted(by_slide.items(), key=lambda kv: slide_no(kv[0])):
|
|
462
|
+
bg_media = None
|
|
463
|
+
for s in sh:
|
|
464
|
+
if s.get('kind') == 'pic' and s.get('w_pct', 0) >= 95 and s.get('h_pct', 0) >= 95:
|
|
465
|
+
bg_media = s.get('media')
|
|
466
|
+
break
|
|
467
|
+
if bg_media is None:
|
|
468
|
+
bg_media = bg_of_layout.get(layout_of_slide.get(part))
|
|
469
|
+
texts = []
|
|
470
|
+
for s in sh:
|
|
471
|
+
if s.get('kind') != 'sp':
|
|
472
|
+
continue
|
|
473
|
+
txt = shape_text(s)
|
|
474
|
+
if not txt:
|
|
475
|
+
continue
|
|
476
|
+
b = s.get('box') or {}
|
|
477
|
+
if b.get('w', 0) < DECOR_MIN or b.get('h', 0) < 16:
|
|
478
|
+
continue
|
|
479
|
+
texts.append({'sz': shape_sz(s), 'box': b, 'txt': txt})
|
|
480
|
+
texts.sort(key=lambda t: (-t['sz'], t['box'].get('y', 0)))
|
|
481
|
+
pics = [s for s in sh if s.get('kind') == 'pic' and s.get('w_pct', 0) < 95]
|
|
482
|
+
pages.append({'part': part, 'no': slide_no(part), 'bg_media': bg_media,
|
|
483
|
+
'bg_color': bg_of_slide.get(part), 'texts': texts, 'pic_n': len(pics),
|
|
484
|
+
'shape_n': len(sh)})
|
|
485
|
+
|
|
486
|
+
def kind_of(p):
|
|
487
|
+
n = len(p['texts'])
|
|
488
|
+
top = p['texts'][0]['sz'] if p['texts'] else 0
|
|
489
|
+
if p['no'] == 1:
|
|
490
|
+
return 'cover'
|
|
491
|
+
if n <= 3 and top >= 60:
|
|
492
|
+
return 'section'
|
|
493
|
+
if n >= 8 or p['pic_n'] >= 4:
|
|
494
|
+
return 'content-dense'
|
|
495
|
+
return 'content'
|
|
496
|
+
|
|
497
|
+
groups = defaultdict(list)
|
|
498
|
+
for p in pages:
|
|
499
|
+
groups[(p['bg_media'] or p['bg_color'] or 'none', kind_of(p))].append(p)
|
|
500
|
+
|
|
501
|
+
ranked = sorted(groups.items(), key=lambda kv: (-len(kv[1]), kv[1][0]['no']))
|
|
502
|
+
kept = [g for g in ranked if len(g[1]) >= 2 or g[0][1] == 'cover'][:8]
|
|
503
|
+
for g in ranked: # 名额没用满就把最大的孤例页也收进来
|
|
504
|
+
if len(kept) >= 8:
|
|
505
|
+
break
|
|
506
|
+
if g not in kept:
|
|
507
|
+
kept.append(g)
|
|
508
|
+
leftover = sorted(p['no'] for g in ranked if g not in kept for p in g[1])
|
|
509
|
+
|
|
510
|
+
archetypes = []
|
|
511
|
+
used = Counter()
|
|
512
|
+
for (bg_raw, kind), ps in kept:
|
|
513
|
+
rep = max(ps, key=lambda p: len(p['texts']))
|
|
514
|
+
used[kind] += 1
|
|
515
|
+
name = kind if used[kind] == 1 else '%s-%d' % (kind, used[kind])
|
|
516
|
+
# 标题按「位置 + 跨度」认,不按字号——巨号数值(21%、7,869)常比标题还大
|
|
517
|
+
band = [t for t in rep['texts']
|
|
518
|
+
if t['box'].get('y', 1e9) < cH * 0.28 and t['box'].get('w', 0) >= cW * 0.25]
|
|
519
|
+
title = max(band, key=lambda t: t['sz']) if band else (
|
|
520
|
+
max(rep['texts'], key=lambda t: t['sz']) if rep['texts'] else None)
|
|
521
|
+
rest = [t for t in rep['texts'] if t is not title]
|
|
522
|
+
rest.sort(key=lambda t: (t['box'].get('y', 0), t['box'].get('x', 0)))
|
|
523
|
+
ordered = ([title] if title else []) + rest
|
|
524
|
+
slots = []
|
|
525
|
+
for i, t in enumerate(ordered[:6]):
|
|
526
|
+
b = t['box']
|
|
527
|
+
if t is title:
|
|
528
|
+
role = typ = 'title'
|
|
529
|
+
elif (title and i == 1 and t['sz'] >= 28
|
|
530
|
+
and abs(b.get('x', 0) - title['box'].get('x', 0)) < 120
|
|
531
|
+
and 0 <= b.get('y', 0) - (title['box'].get('y', 0)
|
|
532
|
+
+ title['box'].get('h', 0)) < 220):
|
|
533
|
+
role = typ = 'subtitle'
|
|
534
|
+
else:
|
|
535
|
+
role = typ = 'body'
|
|
536
|
+
slots.append({'role': role, 'box': [round(b.get('x', 0)), round(b.get('y', 0)),
|
|
537
|
+
round(b.get('w', 0)), round(b.get('h', 0))],
|
|
538
|
+
'type': typ, 'sz': t['sz'], 'txt': t['txt']})
|
|
539
|
+
archetypes.append({'name': name, 'bg': None, 'bg_raw': bg_raw, 'slots': slots,
|
|
540
|
+
'pages': sorted(p['no'] for p in ps), 'rep': rep['no'],
|
|
541
|
+
'pic_n': rep['pic_n'],
|
|
542
|
+
'confidence': 'high' if len(ps) >= 3 else
|
|
543
|
+
('medium' if len(ps) == 2 else 'low')})
|
|
544
|
+
return archetypes, pages, leftover
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
# ---------------------------------------------------------------- 联系表
|
|
548
|
+
def layout_sheet(outdir, archetypes, path):
|
|
549
|
+
"""把各 archetype 的代表页光栅出来拼成一张——版式命名得看得见页面。"""
|
|
550
|
+
reps = [a['rep'] for a in archetypes]
|
|
551
|
+
if not reps:
|
|
552
|
+
return None
|
|
553
|
+
import subprocess
|
|
554
|
+
r = subprocess.run([sys.executable, os.path.join(HERE, 'render_pages.py'), outdir,
|
|
555
|
+
'--pages', 'slides', '--only', ','.join(map(str, reps)), '--no-html'],
|
|
556
|
+
capture_output=True, text=True)
|
|
557
|
+
png_dir = os.path.join(outdir, 'ref', 'rebuild', 'png')
|
|
558
|
+
if r.returncode or not os.path.isdir(png_dir):
|
|
559
|
+
return None
|
|
560
|
+
try:
|
|
561
|
+
from PIL import Image, ImageDraw
|
|
562
|
+
except Exception:
|
|
563
|
+
return None
|
|
564
|
+
cols = 2 if len(archetypes) > 1 else 1
|
|
565
|
+
cw, ch, pad, lab = 480, 270, 16, 20
|
|
566
|
+
rows = (len(archetypes) + cols - 1) // cols
|
|
567
|
+
sheet = Image.new('RGB', (cols * (cw + pad) + pad, rows * (ch + pad + lab) + pad),
|
|
568
|
+
(245, 245, 247))
|
|
569
|
+
dr = ImageDraw.Draw(sheet)
|
|
570
|
+
for i, a in enumerate(archetypes):
|
|
571
|
+
x = pad + (i % cols) * (cw + pad)
|
|
572
|
+
y = pad + (i // cols) * (ch + pad + lab)
|
|
573
|
+
f = os.path.join(png_dir, 'slide-%d.png' % a['rep'])
|
|
574
|
+
if os.path.exists(f):
|
|
575
|
+
im = Image.open(f).convert('RGB')
|
|
576
|
+
im.thumbnail((cw, ch))
|
|
577
|
+
sheet.paste(im, (x, y))
|
|
578
|
+
dr.rectangle([x, y, x + cw, y + ch], outline=(120, 120, 128))
|
|
579
|
+
dr.text((x + 2, y + ch + 5), '[%s] slide %d x%d pages bg=%s'
|
|
580
|
+
% (a['name'], a['rep'], len(a['pages']), a.get('bg') or '-'),
|
|
581
|
+
fill=(20, 20, 24))
|
|
582
|
+
sheet.save(path, optimize=True)
|
|
583
|
+
return path
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def contact_sheet(outdir, cands, path):
|
|
587
|
+
try:
|
|
588
|
+
from PIL import Image, ImageDraw
|
|
589
|
+
except Exception:
|
|
590
|
+
return None
|
|
591
|
+
cell, pad, cols = 220, 20, 4
|
|
592
|
+
items = cands[:12]
|
|
593
|
+
if not items:
|
|
594
|
+
return None
|
|
595
|
+
rows = (len(items) + cols - 1) // cols
|
|
596
|
+
W = cols * (cell + pad) + pad
|
|
597
|
+
H = rows * (cell + pad + 18) + pad
|
|
598
|
+
sheet = Image.new('RGB', (W, H), (245, 245, 247))
|
|
599
|
+
dr = ImageDraw.Draw(sheet)
|
|
600
|
+
for idx, c in enumerate(items):
|
|
601
|
+
x = pad + (idx % cols) * (cell + pad)
|
|
602
|
+
y = pad + (idx // cols) * (cell + pad + 18)
|
|
603
|
+
# 棋盘格底,透明区看得见
|
|
604
|
+
for gy in range(0, cell, 16):
|
|
605
|
+
for gx in range(0, cell, 16):
|
|
606
|
+
if (gx // 16 + gy // 16) % 2 == 0:
|
|
607
|
+
dr.rectangle([x + gx, y + gy, x + gx + 15, y + gy + 15], fill=(214, 214, 218))
|
|
608
|
+
try:
|
|
609
|
+
im = Image.open(os.path.join(outdir, c['out'])).convert('RGBA')
|
|
610
|
+
im.thumbnail((cell, cell))
|
|
611
|
+
sheet.paste(im, (x + (cell - im.width) // 2, y + (cell - im.height) // 2), im)
|
|
612
|
+
except Exception:
|
|
613
|
+
dr.text((x + 8, y + 8), 'unreadable', fill=(200, 0, 0))
|
|
614
|
+
dr.rectangle([x, y, x + cell, y + cell], outline=(120, 120, 128))
|
|
615
|
+
dr.text((x + 2, y + cell + 4), '[%d] %s %dx%d used=%d'
|
|
616
|
+
% (idx + 1, c['file'], c['probe'].get('w') or 0, c['probe'].get('h') or 0, c['n']),
|
|
617
|
+
fill=(20, 20, 24))
|
|
618
|
+
sheet.save(path, optimize=True)
|
|
619
|
+
return path
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
# ---------------------------------------------------------------- 落盘
|
|
623
|
+
def write(p, s):
|
|
624
|
+
with open(p, 'w', encoding='utf-8') as f:
|
|
625
|
+
f.write(s)
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def emit_manifest(d, assets, ldir):
|
|
629
|
+
L = ['version: alpha',
|
|
630
|
+
'name: TODO-style-name # 英文 kebab,体现气质,不要用文件名',
|
|
631
|
+
'name_zh: TODO中文名',
|
|
632
|
+
'description: >',
|
|
633
|
+
' TODO: 一句话说清这套模板的视觉性格(底色 / 主色 / 字形 / 版面骨架),给消费模型定调。']
|
|
634
|
+
themes = d['theme_topology'].get('themes') or ['single']
|
|
635
|
+
if themes != ['single'] and len(themes) > 1:
|
|
636
|
+
L += ['themes: [%s]' % ', '.join(themes), 'default-theme: %s' % themes[0]]
|
|
637
|
+
if assets:
|
|
638
|
+
L.append('assets:')
|
|
639
|
+
for a in assets:
|
|
640
|
+
L.append(' - id: %s' % a['id'])
|
|
641
|
+
L.append(' source_media: %s' % a['src']['file'])
|
|
642
|
+
L.append(' kind: %s' % a['kind'])
|
|
643
|
+
if a['role']:
|
|
644
|
+
L.append(' role: %s' % a['role'])
|
|
645
|
+
if a['kind'] in ('logo', 'slogan'):
|
|
646
|
+
L.append(' on-bg: %s' % (a.get('on_bg') or 'light'))
|
|
647
|
+
if a['use_full']:
|
|
648
|
+
L.append(' use_full: true')
|
|
649
|
+
write(os.path.join(ldir, 'manifest.yaml'), '\n'.join(L) + '\n')
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def emit_frontmatter(d, tokens, fonts, roles, anchors, gaps, ldir):
|
|
653
|
+
L = ['colors:']
|
|
654
|
+
for name, r in tokens:
|
|
655
|
+
L.append(' %s: "%s"' % (name, r['hex']))
|
|
656
|
+
body_font = fonts[1] if len(fonts) > 1 else (fonts[0] if fonts else None)
|
|
657
|
+
disp_font = fonts[0] if fonts else None
|
|
658
|
+
if disp_font:
|
|
659
|
+
L.append('typography:')
|
|
660
|
+
L.append(" fontFamily: '%s'" % font_css(disp_font['stack']))
|
|
661
|
+
if body_font and body_font is not disp_font:
|
|
662
|
+
L.append(" bodyFontFamily: '%s'" % font_css(body_font['stack']))
|
|
663
|
+
for role, t in roles.items():
|
|
664
|
+
lh = lh_of(t)
|
|
665
|
+
L.append(' %s: {fontSize: %dpx%s}' % (
|
|
666
|
+
role, round(t['sz_px']), ', lineHeight: %s' % lh if lh else ''))
|
|
667
|
+
sp = d.get('spacing_candidates') or {}
|
|
668
|
+
pads = sp.get('paddings') or []
|
|
669
|
+
edge = {}
|
|
670
|
+
for p in pads:
|
|
671
|
+
edge.setdefault(p['edge'], p['px'])
|
|
672
|
+
if edge:
|
|
673
|
+
L.append('spacing:')
|
|
674
|
+
L.append(' page-padding: {top: %s, right: %s, bottom: %s, left: %s}'
|
|
675
|
+
% (edge.get('top', 73), edge.get('right', 90),
|
|
676
|
+
edge.get('bottom', 95), edge.get('left', 90)))
|
|
677
|
+
radii = [r for r in (d.get('radii_census') or []) if r['px'] >= 2 and r['n'] >= 6]
|
|
678
|
+
if radii:
|
|
679
|
+
top = max(radii, key=lambda r: r['n'])
|
|
680
|
+
L.append('rounded:')
|
|
681
|
+
L.append(' card: %dpx' % round(top['px']))
|
|
682
|
+
L.append('safe-area:')
|
|
683
|
+
L.append(' content: {top: %s, right: %s, bottom: %s, left: %s, applies-to: [content]}'
|
|
684
|
+
% (edge.get('top', 73), edge.get('right', 90), edge.get('bottom', 95), edge.get('left', 90)))
|
|
685
|
+
L.append(' confidence: medium')
|
|
686
|
+
L.append('anchors:')
|
|
687
|
+
for aid, typ, desc in anchors:
|
|
688
|
+
L.append(' - {id: %s, type: %s, desc: "%s"}' % (aid, typ, desc))
|
|
689
|
+
L.append('gaps:')
|
|
690
|
+
for g in gaps:
|
|
691
|
+
L.append(' - "%s"' % g)
|
|
692
|
+
write(os.path.join(ldir, 'frontmatter.yaml'), '\n'.join(L) + '\n')
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def emit_layouts(archetypes, ldir):
|
|
696
|
+
L = ['# 只改 names 这一段:给每个页型起表意的中文名(看 layout-sheet.png)。下面 layouts 段不要动。',
|
|
697
|
+
'names:']
|
|
698
|
+
for a in archetypes:
|
|
699
|
+
L.append(' %s: TODO中文名(代表页 %s,共 %d 页)' % (a['name'], a['rep'], len(a['pages'])))
|
|
700
|
+
L.append('layouts:')
|
|
701
|
+
for a in archetypes:
|
|
702
|
+
L.append(' %s:' % a['name'])
|
|
703
|
+
L.append(' role: %s' % a['name'].split('-')[0])
|
|
704
|
+
if a['bg']:
|
|
705
|
+
L.append(' background: %s' % a['bg'])
|
|
706
|
+
L.append(' text_safe: TODO安全文字区[x,y,w,h],按背景主体避让后填写')
|
|
707
|
+
L.append(' avoid: TODO禁放区列表;无禁放区写 [],有则写 [{box: [x,y,w,h], reason: "..."}]')
|
|
708
|
+
L.append(' pairing_rule: "TODO说明该页型必须配这张背景时,标题/正文/图表需要避让哪些区域"')
|
|
709
|
+
L.append(' slots:')
|
|
710
|
+
for s in a['slots']:
|
|
711
|
+
L.append(' - {role: %s, box: %s, type: %s}' % (s['role'], s['box'], s['type']))
|
|
712
|
+
L.append(' confidence: %s' % a.get('confidence', 'medium'))
|
|
713
|
+
write(os.path.join(ldir, 'layouts.yaml'), '\n'.join(L) + '\n')
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, ldir):
|
|
717
|
+
canvas = d['canvas']['px']
|
|
718
|
+
cover = next((a for a in assets if a['id'] == 'bg-cover'), None)
|
|
719
|
+
logo = next((a for a in assets if a['kind'] == 'logo'), None)
|
|
720
|
+
content_bgs = [a['id'] for a in assets
|
|
721
|
+
if a.get('kind') == 'background' and a.get('role') == 'content']
|
|
722
|
+
key_assets = []
|
|
723
|
+
for a in assets[:8]:
|
|
724
|
+
role = a.get('role') or a.get('kind')
|
|
725
|
+
key_assets.append('`%s` -> `%s` (%s)' % (a['id'], a.get('out_rel') or a['id'], role))
|
|
726
|
+
|
|
727
|
+
L = ['## Agent Fast Path', '',
|
|
728
|
+
'消费本风格时先读这一节;它是给生成 Agent 的短路径,目标是把风格理解控制在 1 分钟内,避免把审计材料重新理解一遍。',
|
|
729
|
+
'',
|
|
730
|
+
'- **时间预算**:风格导入最多做 1 次 `read_file design.md`;如需坐标,最多再读 1 次 `layouts.md`。完成这两步后必须直接开始生成,不要再探索风格包。',
|
|
731
|
+
'- **只读入口**:常规生成只需要 `design.md`;需要坐标时再打开 `layouts.md`。',
|
|
732
|
+
'- **附件/zip 兜底**:如果当前内容来自 zip 附件的文本摘要,直接使用摘要中 `design.md` / `layouts.md` 的文本;不要尝试修复 zip、解析二进制、搜索附件目录或重建压缩包。',
|
|
733
|
+
'- **禁止动作**:不要读取 `ref/color-freq-raw.json`、`ref/font-clusters.json`、`ref/extract.json`、`ref/rebuild/`、`ref/rebuild/png/*`;不要 summarize / view 参考页;不要重新统计颜色、字体或版式。',
|
|
734
|
+
'- **信息来源优先级**:本节 > `## Usage` > frontmatter `assets` / `colors` / `typography` > `layouts.md`。除此之外的文件只用于人工审计,不用于生成。',
|
|
735
|
+
'- **缺信息时降级**:如果某个细节本节没有写,用 frontmatter token 和最接近的 `layouts.md` archetype 推断;不要打开审计文件补证。',
|
|
736
|
+
'- **画布**:所有坐标按 `%dx%d` 绝对像素理解。' % (canvas[0], canvas[1])]
|
|
737
|
+
if cover:
|
|
738
|
+
L.append('- **封面背景**:优先使用 `%s`;整幅铺满画布,禁止自造渐变替代。' % cover['id'])
|
|
739
|
+
if content_bgs:
|
|
740
|
+
L.append('- **内容页背景**:按 `layouts.md` 中 archetype 的 `background` 字段取;常用内容背景为 %s。'
|
|
741
|
+
% '、'.join('`%s`' % x for x in content_bgs[:4]))
|
|
742
|
+
if cover or content_bgs:
|
|
743
|
+
L.append('- **背景安全区**:背景图和版式必须配对。按 archetype 的 `background`、`text_safe`、`avoid` 一起放文字和卡片;标题、正文、关键数字、图表、卡片、时间线及其容器的外接矩形都不得压到背景视觉主体、强光斑、深色透明区上,透明容器也不能跨进禁放区。')
|
|
744
|
+
if logo:
|
|
745
|
+
L.append('- **标识资产**:只使用 `%s`;不得重画、不得改比例。' % logo['id'])
|
|
746
|
+
L += ['- **配色与字体**:颜色只取 frontmatter `colors`;字体/字号只取 frontmatter `typography`;内容主题不得引入新色相。',
|
|
747
|
+
'- **版式**:优先使用下面内联版式索引;需要更多 slot 时才读 `layouts.md`;不要用 `ref/rebuild/png` 反推坐标。']
|
|
748
|
+
if archetypes:
|
|
749
|
+
L += ['', '内联版式索引(先用这里,不要为了选页型再读文件):']
|
|
750
|
+
for a in archetypes:
|
|
751
|
+
parts = ['`%s`' % a['name']]
|
|
752
|
+
if a.get('bg'):
|
|
753
|
+
parts.append('背景 `%s`' % a['bg'])
|
|
754
|
+
slots = []
|
|
755
|
+
for s in a['slots'][:4]:
|
|
756
|
+
slots.append('%s/%s %s' % (s['role'], s['type'], s['box']))
|
|
757
|
+
L.append('- %s:%s' % (' / '.join(parts), ';'.join(slots) or '按最接近用途套用'))
|
|
758
|
+
if key_assets:
|
|
759
|
+
L += ['', '关键资产:'] + ['- ' + a for a in key_assets]
|
|
760
|
+
|
|
761
|
+
L += ['', '## Overview', '',
|
|
762
|
+
'TODO: 两三句话讲清这套模板的性格与适用场景——看过联系表和页面事实之后再写,不要套话。', '',
|
|
763
|
+
'画布 %dx%d px,%d 页样张归纳出 %d 种页型;版式坐标全部放在 `layouts.md`。'
|
|
764
|
+
% (canvas[0], canvas[1], d['counts']['slides'], len(archetypes)), '',
|
|
765
|
+
'## Usage', '',
|
|
766
|
+
'搭一页 PPT 就三步:', '',
|
|
767
|
+
'1. **挑版式** —— 打开 `layouts.md`,按用途选一个 archetype,`slots[].box` 的 `[x, y, w, h]` 是 %dx%d 画布上的绝对像素,直接照搬,不要自己排版;同时遵守该 archetype 的 `text_safe` 和 `avoid`。'
|
|
768
|
+
% (canvas[0], canvas[1])]
|
|
769
|
+
if assets:
|
|
770
|
+
L.append('2. **铺资产** —— 背景和标识只用 `assets/` 里的文件,见下表;不要自造渐变或重画 logo。背景必须和版式配对,文字必须留在安全区内。')
|
|
771
|
+
else:
|
|
772
|
+
L.append('2. **铺背景** —— 本包无图片资产,背景用 `colors` 里的底色。')
|
|
773
|
+
L += ['3. **填色与字** —— 颜色只从 frontmatter 的 `colors` 取,字号只从 `typography` 取。', '']
|
|
774
|
+
if assets:
|
|
775
|
+
L += ['{{ASSET_TABLE}}', '']
|
|
776
|
+
L += ['## Background Safety', '',
|
|
777
|
+
'真实背景不是纯装饰底。填写本包时必须根据 `contact-sheet.png` 和 `layout-sheet.png` 补充每个 archetype 的 `text_safe`、`avoid`、`pairing_rule`;消费时文字、图表、卡片、表格、时间线、标题容器、正文容器和宽透明容器的外接矩形不得与 `avoid` 区域重叠。内容量超出安全区时换页型或拆页,不要扩大文字区域压住背景主体。', '']
|
|
778
|
+
L += ['**色板纪律**:整套页面只用 `colors` 里的 %d 个 token,内容主题(咖啡、医疗、金融……)不改变配色;'
|
|
779
|
+
'需要强调时用 `primary`,不要引入模板外的色相。' % len(tokens), '',
|
|
780
|
+
'## Colors', '', '| token | 值 | 用途 |', '|---|---|---|']
|
|
781
|
+
USE = {'surface': '页面与卡片主底色', 'surface-alt': '次级底色,分区/强调区块的浅底',
|
|
782
|
+
'ink': '正文与标题文字色', 'ink-muted': '次级文字色,说明与标签',
|
|
783
|
+
'primary': '主强调色:图表主序列、关键数字、行动点',
|
|
784
|
+
'accent': '副强调色,多与 primary 组成渐变',
|
|
785
|
+
'accent-2': '渐变与图表的第二落点色', 'accent-3': '渐变收尾色,用量最少',
|
|
786
|
+
'neutral': '中性弱化色:分隔线、次要标签'}
|
|
787
|
+
for name, r in tokens:
|
|
788
|
+
L.append('| `%s` | `%s` | %s |' % (name, r['hex'], USE.get(name, '按 token 名对应的角色使用')))
|
|
789
|
+
imp, webs = import_line(fonts)
|
|
790
|
+
L += ['', '## Typography', '']
|
|
791
|
+
for f in fonts[:2]:
|
|
792
|
+
L.append('- **%s** —— 栈 `%s`%s' % (
|
|
793
|
+
f['names'][0], font_css(f['stack']),
|
|
794
|
+
',源为商业/内部字体无 web 分发源,按气质降级到 %s' % f['stack'][1]
|
|
795
|
+
if len(f['stack']) > 1 else ''))
|
|
796
|
+
L += ['', '字号轴:' + '、'.join('%s %dpx' % (k, round(v['sz_px'])) for k, v in roles.items()), '',
|
|
797
|
+
'字体加载(**HARD REQUIREMENT:下面这行 @import 原样写入全局样式首行,禁止替换为 '
|
|
798
|
+
'fonts.googleapis.com 或其他域**):', '', '```', imp, '```', '',
|
|
799
|
+
'镜像只保证 wght 400 一档,更粗的字重由浏览器合成,不要把字重当唯一区分手段;'
|
|
800
|
+
'系统字体 PingFang SC / Microsoft YaHei 置于栈末保底,中文场景负字距一律清零。', '',
|
|
801
|
+
'## Layouts', '',
|
|
802
|
+
'**搭页前先读 `layouts.md`**——%d 个 archetype 的 slot 坐标都在那里,本文件不重复。'
|
|
803
|
+
% len(archetypes), '', '{{LAYOUT_LIST}}']
|
|
804
|
+
L += ['', '## Hard Rules', '']
|
|
805
|
+
if cover:
|
|
806
|
+
L.append('- 封面页背景必须铺 `bg-cover`(文件见 Usage 表),整幅铺满 %dx%d,不要自造渐变或换图。'
|
|
807
|
+
% (canvas[0], canvas[1]))
|
|
808
|
+
if any(a['role'] == 'content' for a in assets):
|
|
809
|
+
L.append('- 内容页背景整幅铺满,用哪一张按 `layouts.md` 里该 archetype 的 `background` 字段取,不要混用。')
|
|
810
|
+
if logo:
|
|
811
|
+
b, pages = logo['src']['box'], logo['src']['slides']
|
|
812
|
+
where = ('只出现在源第 %s 页' % '、'.join(map(str, pages))
|
|
813
|
+
if pages and len(pages) <= 4 else '每页放一次')
|
|
814
|
+
L.append('- `%s` 放在 (%d, %d),尺寸 %dx%d px,%s;不要重画、不要替换成文字、不要改比例。'
|
|
815
|
+
% (logo['id'], b.get('x', 0), b.get('y', 0), b.get('w', 0), b.get('h', 0), where))
|
|
816
|
+
L += ['- 版式坐标只从 `layouts.md` 的 `slots[].box` 取。',
|
|
817
|
+
'- 颜色只用 `colors` 里的 token;字号只用 `typography` 里的档位。',
|
|
818
|
+
'- `@import` 行原样写入,禁止替换域名。',
|
|
819
|
+
'- TODO: 补 1-2 条这套模板特有的硬规则(看过联系表之后写,例如主色只许用在哪类元素)。',
|
|
820
|
+
'', '## Exceptions', '']
|
|
821
|
+
if exceptions:
|
|
822
|
+
L += ['- ' + e for e in exceptions]
|
|
823
|
+
else:
|
|
824
|
+
L.append('- 无额外例外:所有页型都遵守上面的安全区与色板纪律。')
|
|
825
|
+
L.append('')
|
|
826
|
+
write(os.path.join(ldir, 'body.md'), '\n'.join(L) + '\n')
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
def emit_brief(d, ctx, ldir):
|
|
830
|
+
(tokens, rest, fonts, roles, assets, rejected, todos, archetypes, cands, sheet,
|
|
831
|
+
leftover, lsheet) = ctx
|
|
832
|
+
canvas = d['canvas']['px']
|
|
833
|
+
L = ['# 抽取简报(草案已生成,读完这一页就能改)', '',
|
|
834
|
+
'源:`%s` 画布 %dx%d %d 页 / %d 版式 主题 %s form=%s'
|
|
835
|
+
% (d['source']['filename'], canvas[0], canvas[1], d['counts']['slides'],
|
|
836
|
+
d['counts']['layouts'], d['theme_topology']['themes'],
|
|
837
|
+
d['form_hint']['form']), '',
|
|
838
|
+
'## 待判断(草案里已标 TODO,逐条改掉)', '']
|
|
839
|
+
base_todos = ['给风格起名:`manifest.yaml` 的 name / name_zh / description(看两张图定气质)',
|
|
840
|
+
'`layouts.yaml` 顶部 `names:` 一段填 %d 个中文页型名(看 layout-sheet.png,'
|
|
841
|
+
'一次改完;下面 layouts 段不要动)' % len(archetypes),
|
|
842
|
+
'`body.md` 的 Overview 与 Hard Rules 末条(Colors 用途列草案已填好,觉得不对再改)']
|
|
843
|
+
for t in base_todos + todos:
|
|
844
|
+
L.append('- ' + t)
|
|
845
|
+
L += ['', '## 联系表(一次看完所有候选图)', '',
|
|
846
|
+
'`l-out/contact-sheet.png` —— 编号对应下表;看完再决定 logo / 封面归属。' if sheet
|
|
847
|
+
else '(Pillow 不可用,未生成联系表;逐张看 `media-out/`)', '',
|
|
848
|
+
'| # | 文件 | 尺寸 | 出现 | 满屏 | 页 | 草案判定 |', '|---|---|---|---|---|---|---|']
|
|
849
|
+
decided = {a['src']['file']: a['id'] for a in assets}
|
|
850
|
+
why = {c['file']: r for c, r in rejected}
|
|
851
|
+
for i, c in enumerate(cands[:12], 1):
|
|
852
|
+
L.append('| %d | `%s` | %sx%s | %d | %s | %s | %s |' % (
|
|
853
|
+
i, c['file'], c['probe'].get('w') or '?', c['probe'].get('h') or '?', c['n'],
|
|
854
|
+
'Y' if c['fullscreen'] else '', ','.join(map(str, c['slides'][:6])) or 'layout',
|
|
855
|
+
decided.get(c['file']) or ('✗ ' + why.get(c['file'], '未采纳'))))
|
|
856
|
+
L += ['', '## 颜色(草案 token 已写进 frontmatter.yaml)', '',
|
|
857
|
+
'| token | hex | 出现 |', '|---|---|---|']
|
|
858
|
+
for name, r in tokens:
|
|
859
|
+
L.append('| `%s` | %s | %d |' % (name, r['hex'], r['n']))
|
|
860
|
+
if rest:
|
|
861
|
+
L.append('')
|
|
862
|
+
L.append('未取用高频色:' + '、'.join('%s(%d)' % (r['hex'], r['n']) for r in rest))
|
|
863
|
+
L += ['', '## 字体 / 字号', '']
|
|
864
|
+
for f in fonts:
|
|
865
|
+
L.append('- `%s` 渲染 %d 处,字重 %s → 降级链 `%s`%s' % (
|
|
866
|
+
f['names'][0], f['rendered'], f['weights'], ' > '.join(f['stack']),
|
|
867
|
+
'(映射表命中 %s)' % f['mapped'] if f['mapped'] else '(映射表未命中,已留原名)'))
|
|
868
|
+
L.append('')
|
|
869
|
+
L.append('字号轴:' + '、'.join('%s=%dpx(n=%d)' % (k, round(v['sz_px']), v['n'])
|
|
870
|
+
for k, v in roles.items()))
|
|
871
|
+
L += ['', '## 版式聚类(草案已写进 layouts.yaml)', '',
|
|
872
|
+
'`l-out/layout-sheet.png` 是各页型代表页的重建图——**看它给页型起名**,'
|
|
873
|
+
'不用再逐页查 shapes。' if lsheet else '(未生成版式图,按下面的 slot 原文命名)', '',
|
|
874
|
+
'| archetype | 页数 | 代表页 | 背景 | slot 数 |', '|---|---|---|---|---|']
|
|
875
|
+
for a in archetypes:
|
|
876
|
+
L.append('| `%s` | %d | %s | %s | %d |' % (
|
|
877
|
+
a['name'], len(a['pages']), a['rep'], a['bg'] or '(无资产底图)', len(a['slots'])))
|
|
878
|
+
if leftover:
|
|
879
|
+
L += ['', '未归入 archetype 的页:%s —— 都是单页孤例,需要就自己补一个 archetype。'
|
|
880
|
+
% ', '.join(map(str, leftover))]
|
|
881
|
+
L += ['', '各 archetype 的 slot 原文(据此起中文页型名、改 role):', '']
|
|
882
|
+
for a in archetypes:
|
|
883
|
+
L.append('- `%s`(第 %s 页,覆盖 %s)' % (a['name'], a['rep'], a['pages']))
|
|
884
|
+
for s in a['slots']:
|
|
885
|
+
L.append(' - %s %spx 「%s」' % (s['role'], round(s['sz']), s['txt']))
|
|
886
|
+
L += ['', '## 下一步', '',
|
|
887
|
+
'1. 看 `contact-sheet.png` 和 `layout-sheet.png`;'
|
|
888
|
+
'2. 用一次批量编辑/patch 改掉四份草案里的 TODO;3. 跑 `package.py`。']
|
|
889
|
+
write(os.path.join(ldir, 'BRIEF.md'), '\n'.join(L) + '\n')
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
def main(argv=None):
|
|
893
|
+
ap = argparse.ArgumentParser()
|
|
894
|
+
ap.add_argument('outdir')
|
|
895
|
+
a = ap.parse_args(argv)
|
|
896
|
+
outdir = os.path.abspath(a.outdir)
|
|
897
|
+
d = json.load(open(os.path.join(outdir, 'extract.json'), encoding='utf-8'))
|
|
898
|
+
ldir = os.path.join(outdir, 'l-out')
|
|
899
|
+
os.makedirs(ldir, exist_ok=True)
|
|
900
|
+
|
|
901
|
+
tokens, rest, _ = draft_colors(d)
|
|
902
|
+
fonts = draft_fonts(d)
|
|
903
|
+
archetypes, pages, leftover = draft_layouts(d, outdir)
|
|
904
|
+
cover_media = next((a['bg_raw'] for a in archetypes if a['name'] == 'cover'), None)
|
|
905
|
+
bg_needed = {a['bg_raw'] for a in archetypes if a['bg_raw'] and a['bg_raw'].startswith('ppt/media')}
|
|
906
|
+
bg_under = {p['no']: p['bg_media'] for p in pages}
|
|
907
|
+
assets, rejected, todos = draft_assets(d, outdir, bg_needed, cover_media, bg_under)
|
|
908
|
+
media_to_asset = {a['src']['media']: a['id'] for a in assets}
|
|
909
|
+
for a in archetypes:
|
|
910
|
+
a['bg'] = media_to_asset.get(a['bg_raw'])
|
|
911
|
+
roles = draft_scale(d, archetypes)
|
|
912
|
+
cands = sorted([c for c in [a['src'] for a in assets]] +
|
|
913
|
+
[c for c, _ in rejected], key=lambda c: (-c['n'], c['file']))
|
|
914
|
+
sheet = contact_sheet(outdir, cands, os.path.join(ldir, 'contact-sheet.png'))
|
|
915
|
+
lsheet = layout_sheet(outdir, archetypes, os.path.join(ldir, 'layout-sheet.png'))
|
|
916
|
+
|
|
917
|
+
anchors = draft_anchors(d, tokens, fonts, roles, assets, archetypes)
|
|
918
|
+
gaps, exceptions = [], []
|
|
919
|
+
for c, why in rejected:
|
|
920
|
+
if '近全透明' in why:
|
|
921
|
+
gaps.append('母版/版式里的 %s 是%s,不是设计资产,任何情况下不要当背景用。' % (c['file'], why))
|
|
922
|
+
for f in fonts[:2]:
|
|
923
|
+
if len(f['stack']) > 1:
|
|
924
|
+
gaps.append('源字体 %s 无 web 授权源,已按 font-fallback 表降级到 %s;字形细节与原稿有差异。'
|
|
925
|
+
% (f['names'][0], f['stack'][1]))
|
|
926
|
+
if leftover:
|
|
927
|
+
exceptions.append('源 deck 第 %s 页是单页孤例,没有归纳成 archetype;需要类似构图时按最接近的页型改。'
|
|
928
|
+
% '、'.join(map(str, leftover)))
|
|
929
|
+
|
|
930
|
+
emit_manifest(d, assets, ldir)
|
|
931
|
+
emit_frontmatter(d, tokens, fonts, roles, anchors, gaps, ldir)
|
|
932
|
+
emit_layouts(archetypes, ldir)
|
|
933
|
+
emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, ldir)
|
|
934
|
+
emit_brief(d, (tokens, rest, fonts, roles, assets, rejected, todos, archetypes, cands, sheet,
|
|
935
|
+
leftover, lsheet), ldir)
|
|
936
|
+
|
|
937
|
+
print('草案就绪 -> %s' % ldir)
|
|
938
|
+
print(' 资产 %d(%s) 版式 %d 色 %d 字体 %d'
|
|
939
|
+
% (len(assets), ', '.join(x['id'] for x in assets), len(archetypes), len(tokens), len(fonts)))
|
|
940
|
+
print(' 先读 l-out/BRIEF.md,再看 l-out/contact-sheet.png')
|
|
941
|
+
return 0
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
if __name__ == '__main__':
|
|
945
|
+
sys.exit(main())
|