@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.
@@ -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())