@lark-apaas/coding-steering 0.1.18-dev.6de99aa → 0.1.18-dev.857e860

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/README.md +19 -21
  2. package/package.json +1 -1
  3. package/steering/design-html/skills/animated-video/SKILL.md +2 -2
  4. package/steering/design-html/skills/charts/SKILL.md +48 -7
  5. package/steering/design-html/skills/{data-report → data-viz}/SKILL.md +65 -9
  6. package/steering/design-html/skills/frontend-design/SKILL.md +2 -2
  7. package/steering/design-html/skills/mini-game/SKILL.md +71 -0
  8. package/steering/design-html/skills/mini-game/references/three-js.md +54 -0
  9. package/steering/design-html/skills/pptx-style-extract/SKILL.md +125 -0
  10. package/steering/design-html/skills/pptx-style-extract/font-fallback.yaml +129 -0
  11. package/steering/design-html/skills/pptx-style-extract/scripts/census.py +955 -0
  12. package/steering/design-html/skills/pptx-style-extract/scripts/check_v2.py +907 -0
  13. package/steering/design-html/skills/pptx-style-extract/scripts/draft.py +1429 -0
  14. package/steering/design-html/skills/pptx-style-extract/scripts/export_consumer_md.py +75 -0
  15. package/steering/design-html/skills/pptx-style-extract/scripts/export_consumer_zip.py +175 -0
  16. package/steering/design-html/skills/pptx-style-extract/scripts/extract.py +772 -0
  17. package/steering/design-html/skills/pptx-style-extract/scripts/ooxml.py +699 -0
  18. package/steering/design-html/skills/pptx-style-extract/scripts/package.py +1076 -0
  19. package/steering/design-html/skills/pptx-style-extract/scripts/parts.py +461 -0
  20. package/steering/design-html/skills/pptx-style-extract/scripts/query.py +562 -0
  21. package/steering/design-html/skills/pptx-style-extract/scripts/render_pages.py +679 -0
  22. package/steering/design-html/skills/pptx-style-extract/scripts/verify_font.py +68 -0
  23. package/steering/design-html/skills/pptx-style-extract/v2-format-spec.md +193 -0
  24. package/steering/design-html/skills/preflight/SKILL.md +26 -131
  25. package/steering/design-html/skills/preflight/scripts/probe.sh +108 -0
  26. package/steering/design-html/skills/slide-deck/SKILL.md +165 -0
  27. package/steering/design-html/skills/{visual-exposure → visual-report}/SKILL.md +24 -2
  28. package/steering/nestjs-react-fullstack/skills_common/trigger-guide/SKILL.md +180 -0
  29. package/steering/nestjs-react-fullstack/{skills/trigger-guide/SKILL.md → skills_common/trigger-guide/references/trigger-lifecycle.md} +11 -162
  30. package/steering/design-html/skills/make-a-deck/SKILL.md +0 -209
@@ -0,0 +1,1429 @@
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 bg_colors(d):
86
+ """页面/版式/母版的 `background` 声明里出现的底色,按声明次数排序。
87
+
88
+ 「哪个色是底色」是直读事实(bgPr / bgRef),不用靠亮度猜——猜过一次,把只在
89
+ 渐变里出现的浅色当成了主底色。
90
+ """
91
+ cnt = Counter()
92
+ rows = (d.get('slides') or []) + (d.get('layouts') or []) \
93
+ + ((d.get('masters') or {}).get('masters') or [])
94
+ for row in rows:
95
+ bg = row.get('background')
96
+ if not isinstance(bg, dict):
97
+ continue
98
+ cols = []
99
+ if isinstance(bg.get('color'), dict):
100
+ cols.append(bg['color'])
101
+ for st in (bg.get('stops') or []):
102
+ if isinstance(st.get('color'), dict):
103
+ cols.append(st['color'])
104
+ for c in cols:
105
+ h = (c.get('hex') or '').upper()
106
+ if h:
107
+ cnt[h] += 1
108
+ return [h for h, _ in cnt.most_common()]
109
+
110
+
111
+ def draft_colors(d, cusage=None):
112
+ """色板 token:名字按**实际用法**定,不只看亮度饱和度。
113
+
114
+ 只看 lum/sat 会把「主要用来填色的纯黑」命名成 ink(文字色)、把「只出现在渐变里的
115
+ 浅蓝」命名成 surface-alt——实测都发生过。这里先看它在形状上主要干什么,再结合
116
+ 亮度定名;用量太少的直接不进色板。
117
+ """
118
+ cusage = cusage or {}
119
+ pool = [c for c in d['color_freq']
120
+ if c.get('class') == 'design' and abs((c.get('alpha') or 100) - 100) < 0.1]
121
+ seen, rows = set(), []
122
+ for c in pool:
123
+ h = c['hex'].upper()
124
+ if h in seen:
125
+ continue
126
+ seen.add(h)
127
+ rgb = hex2rgb(h)
128
+ u = cusage.get(h) or Counter()
129
+ tot = sum(u.values())
130
+ main = u.most_common(1)[0][0] if tot else None
131
+ rows.append({'hex': h, 'n': c['n'], 'lum': lum(rgb), 'sat': satu(rgb),
132
+ 'use': u, 'use_n': tot, 'main': main})
133
+ # 用量只用来**命名**,不作准入门槛——color_usage 只数形状级的填充/描边/文字,
134
+ # 背景 p:bg 与主题色不在其中,拿它筛会把色板砍到只剩一两个(实测塌到 1 个)。
135
+ strong = sorted(rows, key=lambda r: -r['n'])
136
+
137
+ tokens, used = [], set()
138
+
139
+ def take(pred, names):
140
+ for name in names:
141
+ for r in strong:
142
+ if r['hex'] in used or not pred(r):
143
+ continue
144
+ used.add(r['hex'])
145
+ tokens.append((name, r))
146
+ break
147
+
148
+ def kind(r):
149
+ if r['main'] == '文字':
150
+ return 'text'
151
+ if r['main'] in ('填充', '渐变', '描边'):
152
+ return 'paint'
153
+ return 'unknown' # 形状层看不到用法,退回亮度/饱和度判断
154
+
155
+ # 墨色:主要用来写字(或看不出用法但本身是深中性色),且不是彩色
156
+ take(lambda r: r['sat'] < 0.3 and r['lum'] < 0.5
157
+ and (kind(r) == 'text' or kind(r) == 'unknown'), ['ink', 'ink-muted'])
158
+ # 底色:直接取页面 background 声明里的色,按声明次数排
159
+ grounds = bg_colors(d)
160
+ for name in ('surface', 'surface-alt'):
161
+ for h in grounds:
162
+ r = next((x for x in strong if x['hex'] == h and x['hex'] not in used), None)
163
+ if r:
164
+ used.add(r['hex'])
165
+ tokens.append((name, r))
166
+ break
167
+
168
+ # 卡片/面板底:页面底色之外,真被大量当填充铺开的浅色(≥5 处才算)
169
+ take(lambda r: r['lum'] > 0.85 and r['sat'] < 0.2
170
+ and (r['use'].get('填充') or 0) >= 5, ['surface-raised'])
171
+ # 表达色:有彩度的按频次排
172
+ take(lambda r: r['sat'] >= 0.3, ['primary', 'accent', 'accent-2', 'accent-3'])
173
+ # 其余低饱和色一律 neutral-N——它到底是卡片底、分隔线还是描边,数据分不出来,
174
+ # 就不要用名字去替消费方下结论;真实用法写在 Colors 表的用途列里。
175
+ take(lambda r: r['sat'] < 0.3, ['neutral', 'neutral-2', 'neutral-3'])
176
+ rest = [r for r in rows if r['hex'] not in used][:6]
177
+ return tokens, rest, rows
178
+
179
+
180
+
181
+ # ---------------------------------------------------------------- 字体
182
+ def parse_fallback_table():
183
+ path = os.path.join(SKILL_ROOT, 'font-fallback.yaml')
184
+ if not os.path.exists(path):
185
+ return []
186
+ fams, cur = [], None
187
+ for line in open(path, encoding='utf-8'):
188
+ m = re.match(r'\s*-\s*family:\s*(.+)', line)
189
+ if m:
190
+ cur = {'family': m.group(1).strip(), 'match': [], 'fallback': [], 'category': ''}
191
+ fams.append(cur)
192
+ continue
193
+ if cur is None:
194
+ continue
195
+ m = re.match(r'\s*(match|fallback):\s*\[(.*)\]', line)
196
+ if m:
197
+ cur[m.group(1)] = [x.strip().strip('"\'') for x in m.group(2).split(',') if x.strip()]
198
+ m = re.match(r'\s*category:\s*(.+)', line)
199
+ if m:
200
+ cur['category'] = m.group(1).strip()
201
+ return fams
202
+
203
+
204
+ def norm(s):
205
+ return re.sub(r'[\s\-_]', '', s or '').lower()
206
+
207
+
208
+ def cover_slot_colors(tokens, archetypes, rows, cusage):
209
+ """slot 里出现的每个色值都必须在色板里有名字。
210
+
211
+ Hard Rules 写「颜色只用 colors 里的 token」,而 slot 的 color 是从模板直读的,
212
+ 两者不对齐就等于产物自己违反自己的规则(实测 vo-lite 的 slot 用了 #27C5F3 /
213
+ #1664FF 两个色板外的色)。这里把缺的补进色板,按用法归族命名。
214
+ """
215
+ have = {r['hex'].upper() for _, r in tokens}
216
+ by_hex = {r['hex'].upper(): r for r in rows}
217
+ used = [n for n, _ in tokens]
218
+
219
+ def nxt(fam):
220
+ if fam not in used:
221
+ return fam
222
+ i = 2
223
+ while '%s-%d' % (fam, i) in used:
224
+ i += 1
225
+ return '%s-%d' % (fam, i)
226
+
227
+ added = []
228
+ for a in archetypes:
229
+ for s in a['slots']:
230
+ h = (s.get('color') or '').upper()
231
+ if not h.startswith('#') or h in have:
232
+ continue
233
+ have.add(h)
234
+ r = by_hex.get(h)
235
+ if r is None: # 普查里没有这个色(理论上不该发生),跳过不编造
236
+ continue
237
+ fam = ('ink' if r['sat'] < 0.3 and r['lum'] < 0.5
238
+ else 'accent' if r['sat'] >= 0.3 else 'neutral')
239
+ name = nxt(fam)
240
+ used.append(name)
241
+ tokens.append((name, r))
242
+ added.append((name, h))
243
+ return added
244
+
245
+
246
+ def draft_fonts(d):
247
+ table = parse_fallback_table()
248
+ groups = defaultdict(lambda: {'rendered': 0, 'weights': set(), 'names': set(), 'bold': 0})
249
+ for f in d['font_families']:
250
+ if not f.get('rendered_n'):
251
+ continue
252
+ key = f.get('alias_group') or f['family']
253
+ g = groups[key]
254
+ g['rendered'] += f['rendered_n']
255
+ g['names'].add(f['family'])
256
+ g['bold'] += f.get('bold_runs') or 0
257
+ for v in f.get('variants', []):
258
+ if v.get('weight'):
259
+ g['weights'].add(v['weight'])
260
+ ranked = sorted(groups.items(), key=lambda kv: -kv[1]['rendered'])
261
+
262
+ def resolve(names):
263
+ for n in names:
264
+ for fam in table:
265
+ for m in fam['match']:
266
+ if norm(m) == norm(n) or norm(m) in norm(n) or norm(n) in norm(m):
267
+ return fam
268
+ return None
269
+
270
+ out = []
271
+ for key, g in ranked[:4]:
272
+ fam = resolve(sorted(g['names'], key=len))
273
+ stack = [sorted(g['names'], key=len)[0]]
274
+ if fam:
275
+ stack += [x for x in fam['fallback'] if x not in stack]
276
+ out.append({
277
+ 'key': key, 'rendered': g['rendered'], 'names': sorted(g['names']),
278
+ 'weights': sorted(g['weights']) or ([600] if g['bold'] else [400]),
279
+ 'stack': stack, 'mapped': fam['family'] if fam else None,
280
+ 'category': fam['category'] if fam else '',
281
+ })
282
+ return out
283
+
284
+
285
+ def font_css(stack):
286
+ return ', '.join('"%s"' % s for s in stack) + ', ' + SYS_FALLBACK
287
+
288
+
289
+ MIRROR = 'https://miaoda.feishu.cn/fonts/css2'
290
+
291
+
292
+ def import_line(fonts):
293
+ """降级链里用到的镜像字体拼成一行 @import(check_v1 硬要求)。"""
294
+ webs = []
295
+ for f in fonts[:2]:
296
+ for name in f['stack'][1:]:
297
+ if name not in webs:
298
+ webs.append(name)
299
+ if not webs:
300
+ webs = ['Noto Sans SC']
301
+ fam = '&'.join('family=%s:wght@400' % w.replace(' ', '+') for w in webs)
302
+ return "@import url('%s?%s&display=swap');" % (MIRROR, fam), webs
303
+
304
+
305
+ def quant(hit, total):
306
+ """覆盖率决定量词——不到一半就不许说「一律/每页」。"""
307
+ if not total:
308
+ return None
309
+ r = hit / float(total)
310
+ if r >= 0.9:
311
+ return '一律'
312
+ if r >= 0.5:
313
+ return '多数'
314
+ return None
315
+
316
+
317
+ def color_usage(shapes, d=None):
318
+ """每个色值在形状上的真实用法计数:填充 / 渐变 / 描边 / 文字。
319
+
320
+ 用途列以前是写死的字典(把 #7861FF 说成「渐变收尾色」,实测它是填充 14 / 描边 9 /
321
+ 渐变 1)。这里改为从 shapes 直接数,数不到就如实说数不到。
322
+ """
323
+ def hx(c):
324
+ return (c.get('hex') or '').upper() if isinstance(c, dict) else ''
325
+
326
+ def walk_text_colors(node, out):
327
+ """文本样式可能嵌在 lstStyle.lvlNpPr / defRPr / rPr 任一层——通用遍历,
328
+ 别逐层枚举(枚举漏过 lvl2pPr,导致主色被写成「用途待确认」)。"""
329
+ if isinstance(node, dict):
330
+ if isinstance(node.get('color'), dict) and hx(node['color']):
331
+ out.append(hx(node['color']))
332
+ for v in node.values():
333
+ walk_text_colors(v, out)
334
+ elif isinstance(node, list):
335
+ for v in node:
336
+ walk_text_colors(v, out)
337
+
338
+ use = defaultdict(Counter)
339
+ for s in shapes:
340
+ f = s.get('fill') or {}
341
+ if f.get('type') == 'solid' and hx(f.get('color')):
342
+ use[hx(f['color'])]['填充'] += 1
343
+ for st in (f.get('stops') or []):
344
+ if hx(st.get('color')):
345
+ use[hx(st['color'])]['渐变'] += 1
346
+ ln = s.get('line') or {}
347
+ if hx(ln.get('color')):
348
+ use[hx(ln['color'])]['描边'] += 1
349
+ cols = []
350
+ walk_text_colors(s.get('text') or {}, cols)
351
+ for h in cols:
352
+ use[h]['文字'] += 1
353
+ for h in bg_colors(d or {}):
354
+ use[h]['页面背景'] += 1
355
+ # 主题 clrScheme:这类色常常只在主题里声明、页面上由 schemeClr 间接引用,
356
+ # 不记上就会在用途列写「未落在形状上」,看着像没人用。
357
+ for th in ((d or {}).get('themes') or []):
358
+ if not th.get('picked'):
359
+ continue
360
+ for slot, hexv in (th.get('clrScheme') or {}).items():
361
+ if isinstance(hexv, str) and hexv.startswith('#'):
362
+ use[hexv.upper()]['主题 ' + slot] += 1
363
+ return use
364
+
365
+
366
+ def usage_phrase(counter):
367
+ """把用法计数写成一句话;主用法占六成以上就直接点名,否则并列前三。"""
368
+ if not counter:
369
+ return '普查里有声明,但未落在形状/背景/主题色上——用途待确认'
370
+ items = counter.most_common()
371
+ tot = sum(counter.values())
372
+ if items[0][1] >= tot * 0.6:
373
+ return '主要作%s(%d/%d 处)' % (items[0][0], items[0][1], tot)
374
+ return '、'.join('%s %d 处' % (k, v) for k, v in items[:3])
375
+
376
+
377
+ def draft_anchors(d, tokens, fonts, roles, assets, archetypes):
378
+ """anchors 逐条由实测覆盖率产出;证据不足就不生成这一条,不用形容词补。"""
379
+ A = []
380
+ n_arch = len(archetypes) or 1
381
+
382
+ # 1. 表达色:未取用的高频彩色要如实带上,不能说「其余全是中性」
383
+ names = [x[0] for x in tokens]
384
+ chroma = [n for n in names if n.startswith(('primary', 'accent'))]
385
+ if chroma:
386
+ A.append((chroma[0] + '-led-palette', 'token',
387
+ '表达色集中在 %s;其余 token 为底色与文字色' % '、'.join(chroma[:3])))
388
+
389
+ # 2. 圆角:按普查占比
390
+ radii = d.get('radii_census') or []
391
+ zero = next((r for r in radii if r['px'] == 0), None)
392
+ tot_r = sum(r['n'] for r in radii) or 1
393
+ if zero:
394
+ q0 = quant(zero['n'], tot_r)
395
+ if q0:
396
+ A.append(('zero-radius', 'token',
397
+ '卡片、按钮、面板%s直角,圆角量为零的形状占 %d%%'
398
+ % (q0, round(100.0 * zero['n'] / tot_r))))
399
+
400
+ # 3. 满屏底图:按有背景的页型占比
401
+ with_bg = sum(1 for a in archetypes if a.get('bg'))
402
+ qb = quant(with_bg, n_arch)
403
+ if qb:
404
+ A.append(('full-bleed-ground', 'pattern',
405
+ '页型%s由整幅铺满的底图打底(%d/%d),元素浮在图上而不是浮在纯色块上'
406
+ % (qb, with_bg, n_arch)))
407
+
408
+ # 4. 标识:位置是不是真的固定,看有几个不同的 box
409
+ logo_slots = [s for a in archetypes for s in a['slots']
410
+ if str(s.get('asset') or '').startswith(('logo', 'slogan'))]
411
+ logo_arch = sum(1 for a in archetypes
412
+ if any(str(s.get('asset') or '').startswith(('logo', 'slogan'))
413
+ for s in a['slots']))
414
+ boxes = {tuple(s['box']) for s in logo_slots}
415
+ if logo_arch and len(boxes) == 1:
416
+ A.append(('corner-locked-logo', 'component',
417
+ '品牌标识在 %d/%d 个页型上出现,位置尺寸完全一致,跨页不动'
418
+ % (logo_arch, n_arch)))
419
+ elif len(boxes) > 1:
420
+ A.append(('logo-moves-by-archetype', 'component',
421
+ '品牌标识按页型换位换尺寸(共 %d 种摆法),必须按 layouts 里该页型的 box 放,'
422
+ '不能沿用上一页' % len(boxes)))
423
+
424
+ # 5. 渐变:按普查计数
425
+ if (d.get('geom_census') or {}).get('gradient_fills'):
426
+ A.append(('gradient-accent', 'pattern',
427
+ '强调元素用线性渐变承载,全档共 %d 处渐变填充'
428
+ % (d['geom_census']['gradient_fills'])))
429
+
430
+ # 6. 层级:字号跨度 + 字重是否单一(字重真单一才敢说「不靠字重」)
431
+ disp, body = roles.get('display'), roles.get('body')
432
+ if disp and body and disp['sz_px'] >= body['sz_px'] * 3:
433
+ ws = {s.get('weight') for a in archetypes for s in a['slots'] if s.get('weight')}
434
+ tail = (',字重只用 %s 一档' % list(ws)[0]) if len(ws) == 1 else ''
435
+ A.append(('size-driven-hierarchy', 'pattern',
436
+ '层级靠字号跨度拉开,展示档与正文档差 %.1f 倍,见 typography%s'
437
+ % (disp['sz_px'] / body['sz_px'], tail)))
438
+
439
+ # 7. 阴影:只在描边极少时才敢说「不用描边分隔」
440
+ eff = d.get('effects_census') or {}
441
+ if eff.get('outerShdw'):
442
+ A.append(('soft-shadow-card', 'component',
443
+ '容器用外阴影托起,全档 %d 处 outerShdw' % eff['outerShdw']))
444
+
445
+ # 8. 双字族:只陈述分工存在,不断言「同一行混排」(普查没采集混排)
446
+ tot_r_font = sum(f['rendered'] for f in fonts) or 1
447
+ if len(fonts) >= 2 and fonts[1]['rendered'] >= tot_r_font * 0.15:
448
+ A.append(('dual-family-typesetting', 'token',
449
+ '正文与展示分属两套字族:%s 与 %s,各自渲染 %d / %d 处'
450
+ % (fonts[0]['names'][0], fonts[1]['names'][0],
451
+ fonts[0]['rendered'], fonts[1]['rendered'])))
452
+
453
+ # 9. 安全区:只在各页型正文左边界真的收敛时才写
454
+ lefts = [s['box'][0] for a in archetypes for s in a['slots']
455
+ if not s.get('asset') and s['box'][2] > 200]
456
+ if len(lefts) >= 4:
457
+ common = Counter(lefts).most_common(1)[0]
458
+ qs = quant(common[1], len(lefts))
459
+ if qs:
460
+ A.append(('shared-left-margin', 'token',
461
+ '正文%s对齐同一条左边界(%d/%d 个正文槽共用,坐标见 layouts)'
462
+ % (qs, common[1], len(lefts))))
463
+
464
+ # 10. 双主题:直读事实
465
+ themes = (d.get('theme_topology') or {}).get('themes') or []
466
+ if len(themes) > 1:
467
+ A.append(('dual-theme-masters', 'token',
468
+ '模板带 %s 两套主题母版,同一页型有深浅两版,配色随主题整体反转'
469
+ % ' / '.join(themes)))
470
+
471
+ # 11. 画布:直读事实(兜底凑数也只用真事实)
472
+ cv = d['canvas']['px']
473
+ A.append(('fixed-canvas', 'token',
474
+ '画布固定 %d×%d,所有坐标是这张画布上的绝对像素,不做响应式重排'
475
+ % (cv[0], cv[1])))
476
+ if len(archetypes) >= 3:
477
+ A.append(('archetype-catalog', 'pattern',
478
+ '模板给出 %d 种页型,搭页从中挑,不要自创版式' % len(archetypes)))
479
+
480
+ seen, out = set(), []
481
+ for a in A:
482
+ if a[0] in seen:
483
+ continue
484
+ seen.add(a[0])
485
+ out.append(a)
486
+ return out[:8]
487
+
488
+ def draft_scale(d, archetypes=()):
489
+ ts = [t for t in d['text_scale'] if t['sz_px'] >= 10]
490
+ ts.sort(key=lambda t: -t['sz_px'])
491
+ if not ts:
492
+ return {}
493
+ by_px = {t['sz_px']: t for t in ts}
494
+ # display 优先取「真的当标题用过」的字号(archetype 首槽),而不是全局最大值
495
+ title_sz = Counter(s['sz'] for a in archetypes for s in a['slots'] if s['type'] == 'title')
496
+ display = by_px.get(max(title_sz)) if title_sz else None
497
+ big = [t for t in ts if t['n'] >= 2] or ts
498
+ display = display or big[0]
499
+ body_pool = [t for t in ts if t['sz_px'] <= 48]
500
+ body = max(body_pool, key=lambda t: t['n']) if body_pool else ts[-1]
501
+ heading_pool = [t for t in ts if body['sz_px'] * 1.3 <= t['sz_px'] < display['sz_px']]
502
+ heading = max(heading_pool, key=lambda t: t['n']) if heading_pool else None
503
+ small_pool = [t for t in ts if t['sz_px'] < body['sz_px']]
504
+ caption = max(small_pool, key=lambda t: t['n']) if small_pool else None
505
+ roles = {'display': display, 'body': body}
506
+ if heading:
507
+ roles['heading'] = heading
508
+ if caption:
509
+ roles['caption'] = caption
510
+ return roles
511
+
512
+
513
+ def lh_of(t):
514
+ lhm = t.get('line_height_mult') or {}
515
+ if not lhm:
516
+ return None
517
+ best = max(lhm.items(), key=lambda kv: kv[1])[0]
518
+ try:
519
+ v = float(best)
520
+ except ValueError:
521
+ return None
522
+ return v if 0.9 <= v <= 2.2 else None
523
+
524
+
525
+ # ---------------------------------------------------------------- 资产
526
+ def probe_image(path):
527
+ info = {'w': None, 'h': None, 'alpha_mean': None, 'near_blank': False}
528
+ try:
529
+ from PIL import Image
530
+ except Exception:
531
+ return info
532
+ try:
533
+ im = Image.open(path)
534
+ info['w'], info['h'] = im.size
535
+ if im.mode in ('RGBA', 'LA') or 'transparency' in im.info:
536
+ px = im.convert('RGBA').getchannel('A').resize((64, 64)).tobytes()
537
+ info['alpha_mean'] = sum(px) / len(px)
538
+ info['near_blank'] = info['alpha_mean'] < 13 # <5% 不透明度
539
+ except Exception:
540
+ pass
541
+ return info
542
+
543
+
544
+ def copy_logo_candidates(outdir, logo_pool):
545
+ if not logo_pool:
546
+ return []
547
+ dst_dir = os.path.join(outdir, 'ref', 'logo-candidates')
548
+ os.makedirs(dst_dir, exist_ok=True)
549
+ rows = []
550
+ for rank, (score, c) in enumerate(sorted(logo_pool, key=lambda kv: (-kv[0], -kv[1]['n'])), 1):
551
+ src = os.path.join(outdir, c.get('out') or '')
552
+ if not os.path.exists(src):
553
+ continue
554
+ name = '%02d-score%s-%s' % (rank, score, c['file'])
555
+ dst = os.path.join(dst_dir, name)
556
+ shutil.copy2(src, dst)
557
+ b = c.get('box') or {}
558
+ rows.append({
559
+ 'rank': rank,
560
+ 'score': score,
561
+ 'file': c['file'],
562
+ 'copy': os.path.relpath(dst, outdir),
563
+ 'slides': c.get('slides') or [],
564
+ 'box': [round(b.get(k, 0)) for k in ('x', 'y', 'w', 'h')],
565
+ 'used_n': c.get('n', 0),
566
+ })
567
+ if rows:
568
+ with open(os.path.join(dst_dir, 'index.json'), 'w', encoding='utf-8') as f:
569
+ json.dump(rows, f, ensure_ascii=False, indent=2)
570
+ f.write('\n')
571
+ return rows
572
+
573
+
574
+ def draft_assets(d, outdir, bg_needed=(), cover_media=None, bg_under=None):
575
+ imgs = {i['media']: i for i in d['images']}
576
+ cluster_of = {}
577
+ for c in d.get('media_clusters', []):
578
+ for m in c['members']:
579
+ cluster_of[m] = c['content_id']
580
+
581
+ cands = []
582
+ for m in d['media']:
583
+ if not m.get('exported'):
584
+ continue
585
+ img = imgs.get(m['media'], {})
586
+ out_rel = m.get('out') or ''
587
+ probe = probe_image(os.path.join(outdir, out_rel)) if out_rel else {}
588
+ boxes = img.get('boxes') or []
589
+ top = max(boxes, key=lambda b: b.get('count', 0)) if boxes else {}
590
+ parts = top.get('parts') or []
591
+ slides = sorted({slide_no(p) for p in parts if '/slides/' in p})
592
+ cands.append({
593
+ 'media': m['media'], 'file': os.path.basename(out_rel), 'out': out_rel,
594
+ 'bytes': m.get('bytes'), 'n': img.get('n', m.get('used_n', 0)),
595
+ 'has_compressed': bool(m.get('compressed_out')),
596
+ 'fullscreen': bool(img.get('fullscreen')), 'w_pct': img.get('max_w_pct', 0),
597
+ 'box': top.get('box') or {}, 'slides': slides,
598
+ 'layer_only': bool(parts) and not slides,
599
+ 'repeat': bool(img.get('repeat_fixed')),
600
+ 'cluster': cluster_of.get(m['media']),
601
+ 'probe': probe, 'reasons': m.get('reasons', []),
602
+ })
603
+
604
+ # 同素材簇去重:留 n 最大的一张
605
+ best_of = {}
606
+ for c in cands:
607
+ k = c['cluster'] or c['media']
608
+ if k not in best_of or c['n'] > best_of[k]['n']:
609
+ best_of[k] = c
610
+ kept = sorted(best_of.values(), key=lambda c: (-c['n'], -(c['bytes'] or 0)))
611
+ # 被同簇兄弟淘汰的 media 仍要能指到胜出者——封面底图常常是簇里 n 最小的那张
612
+ alias = {}
613
+ for c in cands:
614
+ w = best_of.get(c['cluster'] or c['media'])
615
+ if w and w['media'] != c['media']:
616
+ alias[c['media']] = w['media']
617
+ cover_media = alias.get(cover_media, cover_media)
618
+ bg_needed = {alias.get(m, m) for m in (bg_needed or ())}
619
+
620
+ assets, rejected, todos = [], [], []
621
+ logo_pool = []
622
+ bg_under = bg_under or {}
623
+ bg_i = 0
624
+ canvas_w = d['canvas']['px'][0]
625
+ for c in kept:
626
+ if c['probe'].get('near_blank'):
627
+ rejected.append((c, '近全透明(alpha 均值 %.0f/255),PPT 里看不见' % c['probe']['alpha_mean']))
628
+ continue
629
+ if c['fullscreen']:
630
+ if c['media'] == cover_media:
631
+ assets.append({'id': 'bg-cover', 'kind': 'background', 'role': 'cover',
632
+ 'src': c,
633
+ # 只有真出了压缩版才能带原图;否则 path/full 指向同一
634
+ # 文件,package.py 必 FAIL(封面不需要转码时就会踩到)
635
+ 'use_full': c['has_compressed']})
636
+ elif c['media'] in bg_needed and bg_i < 5:
637
+ bg_i += 1
638
+ assets.append({'id': 'bg-content-%d' % bg_i, 'kind': 'background',
639
+ 'role': 'content', 'src': c, 'use_full': False})
640
+ else:
641
+ rejected.append((c, '满屏图但没有页面以它为主底(只在版式层备用)'))
642
+ elif c['w_pct'] < 30 and c['n'] >= 2:
643
+ b = c['box']
644
+ score = 0
645
+ score += 3 if b.get('y', 999) < 160 else (1 if b.get('y', 0) > canvas_w * 0.5 else 0)
646
+ score += 2 if b.get('x', 999) < 200 or b.get('x', 0) > canvas_w * 0.7 else 0
647
+ ar = (b.get('w') or 1) / max(b.get('h') or 1, 1)
648
+ score += 1 if 1.0 <= ar <= 8.0 else 0
649
+ score += 1 if c['n'] >= 2 else 0
650
+ logo_pool.append((score, c))
651
+ else:
652
+ rejected.append((c, '内容区图片(占宽 %.0f%%,出现 %d 次)' % (c['w_pct'], c['n'])))
653
+
654
+ def on_bg_of(c):
655
+ """logo 压在浅底还是深底:直接采底图上它那块区域的亮度,不用人判。"""
656
+ bg = bg_under.get(c['slides'][0]) if c['slides'] else None
657
+ row = next((m for m in d['media'] if m['media'] == bg and m.get('out')), None)
658
+ if not row:
659
+ return None
660
+ try:
661
+ from PIL import Image
662
+ im = Image.open(os.path.join(outdir, row['out'])).convert('RGB')
663
+ b = c['box']
664
+ sx, sy = im.width / float(canvas_w), im.height / float(d['canvas']['px'][1])
665
+ crop = im.crop((int(b.get('x', 0) * sx), int(b.get('y', 0) * sy),
666
+ max(int((b.get('x', 0) + b.get('w', 1)) * sx), 1),
667
+ max(int((b.get('y', 0) + b.get('h', 1)) * sy), 1))).resize((16, 16))
668
+ raw = crop.tobytes()
669
+ px = [raw[i:i + 3] for i in range(0, len(raw), 3)]
670
+ return 'light' if sum(lum(p) for p in px) / len(px) > 0.55 else 'dark'
671
+ except Exception:
672
+ return None
673
+
674
+ logo_pool.sort(key=lambda kv: (-kv[0], -kv[1]['n']))
675
+ for i, (score, c) in enumerate(logo_pool):
676
+ b = c['box']
677
+ if i == 0 and score >= 5:
678
+ assets.append({'id': 'logo-primary', 'kind': 'logo', 'role': None, 'src': c,
679
+ 'use_full': False, 'on_bg': on_bg_of(c)})
680
+ todos.append('看联系表确认 `%s`(%.0fx%.0f @ %.0f,%.0f,出现 %d 次)真是品牌 logo;'
681
+ '不是就把 manifest 的 logo-primary 换成别的候选或整条删掉'
682
+ % (c['file'], b.get('w', 0), b.get('h', 0), b.get('x', 0), b.get('y', 0), c['n']))
683
+ else:
684
+ rejected.append((c, '重复小图(%.0fx%.0f @ %.0f,%.0f),logo 相似度低于首选'
685
+ % (b.get('w', 0), b.get('h', 0), b.get('x', 0), b.get('y', 0))))
686
+
687
+ # 体量预算:包内资产总量超 20MB 直接 FAIL(V2-6)。`use_full` 的原图是唯一可能
688
+ # 单张爆预算的东西(实测某模板封面 tif 单张 23.7MB),所以在草案期就先丢 full,
689
+ # 不要留给 L 层去撞门禁再回修。
690
+ PACK_BUDGET = 20 * 1024 * 1024
691
+ est = sum(min(a['src'].get('bytes') or 0, 600 * 1024) for a in assets)
692
+ for a in sorted([x for x in assets if x['use_full']],
693
+ key=lambda x: -(x['src'].get('bytes') or 0)):
694
+ orig = a['src'].get('bytes') or 0
695
+ if est + orig > PACK_BUDGET * 0.9:
696
+ a['use_full'] = False
697
+ todos.append('`%s` 的原图 %.1fMB 会把包撑过 20MB 上限,草案已只保留压缩版;'
698
+ '确实需要原图就改走 url 承载' % (a['id'], orig / 1024.0 / 1024))
699
+ else:
700
+ est += orig
701
+
702
+ if not any(a['role'] == 'cover' for a in assets):
703
+ todos.append('没定出封面底图——从联系表挑一张补进 manifest(role: cover),或在 gaps 写明模板无封面主视觉')
704
+ copy_logo_candidates(outdir, logo_pool)
705
+ return assets, rejected, todos, alias
706
+
707
+
708
+ # ---------------------------------------------------------------- 版式聚类
709
+ DECOR_MIN = 40.0
710
+
711
+ # 版式名 → role(模板自己按页型命名时直接用它,别再猜)
712
+ ROLE_BY_WORD = [('封面', 'cover'), ('cover', 'cover'), ('首页', 'cover'),
713
+ ('封底', 'closing'), ('尾页', 'closing'), ('结束', 'closing'),
714
+ ('致谢', 'closing'), ('谢谢', 'closing'), ('end', 'closing'),
715
+ ('章节', 'section'), ('目录', 'section'), ('过渡', 'section'),
716
+ ('section', 'section'), ('agenda', 'section'),
717
+ ('金句', 'quote'), ('问句', 'quote'), ('引言', 'quote'), ('quote', 'quote'),
718
+ ('空白', 'blank'), ('blank', 'blank')]
719
+ PH_TO_TYPE = {'title': 'title', 'ctrTitle': 'title', 'subTitle': 'subtitle',
720
+ 'body': 'body', 'pic': 'pic', 'clipArt': 'pic', 'tbl': 'table',
721
+ 'chart': 'chart', 'media': 'media', 'dgm': 'pic',
722
+ 'sldNum': 'slide-number', 'ftr': 'footer', 'dt': 'footer'}
723
+
724
+
725
+ def role_of_name(name):
726
+ low = (name or '').lower()
727
+ for word, role in ROLE_BY_WORD:
728
+ if word in low:
729
+ return role
730
+ return 'content'
731
+
732
+
733
+ def clean_layout_name(name):
734
+ """`1_内容-左右排版(无副标题)` → `内容-左右排版(无副标题)`。"""
735
+ return re.sub(r'^\d+[_\-\s]*', '', (name or '').strip()) or '未命名版式'
736
+
737
+
738
+ def slot_style(s):
739
+ """占位符自带的排版样式——字号/色值/对齐/字重都是直读,不给消费端留编的空间。
740
+
741
+ 样式可能在三层:lstStyle.lvl1pPr(版式占位符常用)、段落 defRPr(Mac Office
742
+ 导出把大量属性写在这一层)、段落 pPr(对齐)。逐层兜底,缺一层就往下取。
743
+ """
744
+ txt = s.get('text') or {}
745
+ ls = dict((txt.get('lstStyle') or {}).get('lvl1pPr') or {})
746
+ # 四层逐级兜底,按 OOXML 的就近原则:run rPr → 段落 defRPr → 段落 pPr → lstStyle。
747
+ # 只枚举前几层会整份漏掉——实测两个 PptxGenJS 导出的样本把字号全写在 run rPr 上,
748
+ # 123/123、105/105 个文本形状都有 sz_px,而 lvl1pPr 一个都没有。
749
+ for para in (txt.get('paragraphs') or []):
750
+ srcs = [r.get('rPr') or {} for r in (para.get('runs') or [])]
751
+ srcs.append(para.get('defRPr') or {})
752
+ srcs.append({k: v for k, v in para.items() if k not in ('runs', 'defRPr')})
753
+ for src in srcs:
754
+ for k, v in (src or {}).items():
755
+ if v is not None:
756
+ ls.setdefault(k, v)
757
+ if ls.get('sz_px'):
758
+ break
759
+ if not ls.get('sz_px'):
760
+ # 仍无声明:退到整形状里出现过的最大字号(generic walk),仍是文件里的值
761
+ anysz = shape_sz(s)
762
+ if anysz:
763
+ ls['sz_px'] = anysz
764
+ out = {}
765
+ if ls.get('sz_px'):
766
+ out['size'] = round(ls['sz_px'])
767
+ col = (ls.get('color') or {}).get('resolved')
768
+ if col:
769
+ out['color'] = col
770
+ if ls.get('weight'):
771
+ out['weight'] = ls['weight']
772
+ elif ls.get('bold'):
773
+ out['weight'] = 700
774
+ if ls.get('algn') and ls['algn'] not in ('l', 'just'):
775
+ out['align'] = {'ctr': 'center', 'r': 'right'}.get(ls['algn'], ls['algn'])
776
+ anchor = ((s.get('text') or {}).get('bodyPr') or {}).get('anchor')
777
+ if anchor in ('ctr', 'b'):
778
+ out['valign'] = {'ctr': 'middle', 'b': 'bottom'}[anchor]
779
+ return out
780
+
781
+
782
+ def layouts_from_template(d, shapes, cW, cH):
783
+ """form=3:模板自己用 slideLayout 声明了页型,直接读版式层。
784
+
785
+ 拿样张聚类只能得到「样张数」个 archetype——模板往往只放 1-2 张样张,
786
+ 真正的页型全在版式里。实测某飞书模板 2 张样张 / 31 个语义版式,按样张聚类
787
+ 只出 2 个 archetype,消费端搭 12 页时 10 页无版式可抄,只能自己编。
788
+ """
789
+ by_part = defaultdict(list)
790
+ for s in shapes:
791
+ if s.get('layer') == 'layout' and s.get('ph'):
792
+ by_part[s['part']].append(s)
793
+ bg_of_layout = {}
794
+ for s in shapes:
795
+ if (s.get('layer') == 'layout' and s.get('kind') == 'pic'
796
+ and s.get('w_pct', 0) >= 95 and s.get('h_pct', 0) >= 95):
797
+ bg_of_layout.setdefault(s['part'], s.get('media'))
798
+ topo = d.get('theme_topology') or {}
799
+ theme_of_master = {m['master']: m.get('theme_label')
800
+ for m in (topo.get('per_master') or [])}
801
+ master_of = (d.get('reference_graph') or {}).get('master_of_layout') or {}
802
+ default_theme = topo.get('default')
803
+ multi = len(topo.get('themes') or []) > 1
804
+
805
+ rows = []
806
+ for l in d.get('layouts') or []:
807
+ phs = [s for s in by_part.get(l['part'], []) if (s.get('box') or {}).get('w')]
808
+ if not phs:
809
+ continue
810
+ theme = theme_of_master.get(master_of.get(l['part']))
811
+ phs.sort(key=lambda s: ((s['box'].get('y') or 0), (s['box'].get('x') or 0)))
812
+ slots, seen_kind = [], set()
813
+ for s in phs:
814
+ t = PH_TO_TYPE.get((s['ph'] or {}).get('type'), 'body')
815
+ if t in ('slide-number', 'footer'):
816
+ continue # 页码/页脚属 chrome,不是内容槽
817
+ b = s['box']
818
+ role = t if t in ('title', 'subtitle') else 'body'
819
+ if t == 'title' and 'title' in seen_kind:
820
+ role, t = 'subtitle', 'subtitle'
821
+ seen_kind.add(t)
822
+ row = {'role': role, 'type': t, 'sz': shape_sz(s),
823
+ 'box': [round(b.get('x', 0)), round(b.get('y', 0)),
824
+ round(b.get('w', 0)), round(b.get('h', 0))],
825
+ 'txt': shape_text(s) or (s.get('name') or '')[:24]}
826
+ row.update(slot_style(s))
827
+ slots.append(row)
828
+ # 非满屏的图片元素(logo / 联名标 / 装饰)——它们逐版式换位置换尺寸,
829
+ # 必须按版式落进 slots,压成一条全局「固定位」规则就会撞标题。
830
+ bgm = bg_of_layout.get(l['part'])
831
+ for s in shapes:
832
+ if s['part'] != l['part'] or s.get('kind') != 'pic' or not s.get('media'):
833
+ continue
834
+ if s['media'] == bgm or (s.get('w_pct', 0) >= 95 and s.get('h_pct', 0) >= 95):
835
+ continue
836
+ b = s.get('box') or {}
837
+ if not b.get('w'):
838
+ continue
839
+ slots.append({'role': 'logo', 'type': 'pic', 'sz': 0, 'txt': '',
840
+ 'media': s['media'],
841
+ 'box': [round(b.get('x', 0)), round(b.get('y', 0)),
842
+ round(b.get('w', 0)), round(b.get('h', 0))]})
843
+ if not slots:
844
+ continue
845
+ rows.append({'zh': clean_layout_name(l.get('name')), 'role': role_of_name(l.get('name')),
846
+ 'slots': slots, 'bg_raw': bgm,
847
+ 'theme': theme, 'part': l['part'],
848
+ 'used': l.get('used_by_slides') or 0})
849
+
850
+ # 同名版式在 dark/light 两套 master 下各有一份——按名字归一,优先默认主题那份
851
+ best = {}
852
+ for r in rows:
853
+ k = r['zh']
854
+ cur = best.get(k)
855
+ if cur is None or (r['theme'] == default_theme and cur['theme'] != default_theme) \
856
+ or (r['used'] > cur['used']):
857
+ best[k] = r
858
+ picked = sorted(best.values(), key=lambda r: (
859
+ ['cover', 'section', 'quote', 'content', 'closing', 'blank'].index(r['role'])
860
+ if r['role'] in ('cover', 'section', 'quote', 'content', 'closing', 'blank') else 9,
861
+ -r['used'], r['part']))
862
+
863
+ used_key = Counter()
864
+ arch = []
865
+ for r in picked:
866
+ used_key[r['role']] += 1
867
+ n = used_key[r['role']]
868
+ key = r['role'] if n == 1 else '%s-%d' % (r['role'], n)
869
+ m_no = re.search(r'slideLayout(\d+)\.xml$', r['part'])
870
+ arch.append({'name': key, 'zh': r['zh'], 'role': r['role'], 'bg': None,
871
+ 'bg_raw': r['bg_raw'], 'slots': r['slots'], 'pages': [],
872
+ 'rep': None, 'rep_layout': int(m_no.group(1)) if m_no else None,
873
+ 'pic_n': 0, 'confidence': 'high',
874
+ 'theme': r['theme'] if multi else None,
875
+ 'source': 'layout:' + r['part'].split('/')[-1]})
876
+ return arch
877
+
878
+
879
+ def draft_layouts(d, outdir):
880
+ shapes = json.load(open(os.path.join(outdir, 'ref', 'shapes.json'), encoding='utf-8'))['shapes']
881
+ cW, cH = d['canvas']['px']
882
+ if (d.get('form_hint') or {}).get('form') == 3:
883
+ arch = layouts_from_template(d, shapes, cW, cH)
884
+ if len(arch) >= 3:
885
+ return arch, [], []
886
+ by_slide = defaultdict(list)
887
+ for s in shapes:
888
+ if s.get('layer') == 'slide':
889
+ by_slide[s['part']].append(s)
890
+
891
+ bg_of_slide, layout_of_slide = {}, {}
892
+ for s in d.get('slides', []):
893
+ bg = s.get('background')
894
+ bg_of_slide[s['part']] = json.dumps(bg, sort_keys=True) if isinstance(bg, dict) else bg
895
+ layout_of_slide[s['part']] = s.get('layout')
896
+ # 版式层的满屏底图(form=2 常态:底图挂在 layout 上)
897
+ bg_of_layout = {}
898
+ for s in shapes:
899
+ if (s.get('layer') == 'layout' and s.get('kind') == 'pic'
900
+ and s.get('w_pct', 0) >= 95 and s.get('h_pct', 0) >= 95):
901
+ bg_of_layout.setdefault(s['part'], s.get('media'))
902
+
903
+ pages = []
904
+ for part, sh in sorted(by_slide.items(), key=lambda kv: slide_no(kv[0])):
905
+ bg_media = None
906
+ for s in sh:
907
+ if s.get('kind') == 'pic' and s.get('w_pct', 0) >= 95 and s.get('h_pct', 0) >= 95:
908
+ bg_media = s.get('media')
909
+ break
910
+ if bg_media is None:
911
+ bg_media = bg_of_layout.get(layout_of_slide.get(part))
912
+ texts = []
913
+ for s in sh:
914
+ if s.get('kind') != 'sp':
915
+ continue
916
+ txt = shape_text(s)
917
+ if not txt:
918
+ continue
919
+ b = s.get('box') or {}
920
+ if b.get('w', 0) < DECOR_MIN or b.get('h', 0) < 16:
921
+ continue
922
+ texts.append({'sz': shape_sz(s), 'box': b, 'txt': txt, 'style': slot_style(s)})
923
+ texts.sort(key=lambda t: (-t['sz'], t['box'].get('y', 0)))
924
+ pics = [s for s in sh if s.get('kind') == 'pic' and s.get('w_pct', 0) < 95]
925
+ # 小图元素(logo / 角标 / 装饰)逐页记位置,供 archetype 落 slots
926
+ marks = [{'media': s['media'], 'box': s['box']} for s in pics
927
+ if s.get('media') and (s.get('box') or {}).get('w') and s.get('w_pct', 0) < 30]
928
+ pages.append({'part': part, 'no': slide_no(part), 'bg_media': bg_media,
929
+ 'bg_color': bg_of_slide.get(part), 'texts': texts, 'pic_n': len(pics),
930
+ 'marks': marks, 'shape_n': len(sh)})
931
+
932
+ def kind_of(p):
933
+ n = len(p['texts'])
934
+ top = p['texts'][0]['sz'] if p['texts'] else 0
935
+ if p['no'] == 1:
936
+ return 'cover'
937
+ if n <= 3 and top >= 60:
938
+ return 'section'
939
+ if n >= 8 or p['pic_n'] >= 4:
940
+ return 'content-dense'
941
+ return 'content'
942
+
943
+ groups = defaultdict(list)
944
+ for p in pages:
945
+ groups[(p['bg_media'] or p['bg_color'] or 'none', kind_of(p))].append(p)
946
+
947
+ ranked = sorted(groups.items(), key=lambda kv: (-len(kv[1]), kv[1][0]['no']))
948
+ kept = [g for g in ranked if len(g[1]) >= 2 or g[0][1] == 'cover'][:8]
949
+ for g in ranked: # 名额没用满就把最大的孤例页也收进来
950
+ if len(kept) >= 8:
951
+ break
952
+ if g not in kept:
953
+ kept.append(g)
954
+ leftover = sorted(p['no'] for g in ranked if g not in kept for p in g[1])
955
+
956
+ archetypes = []
957
+ used = Counter()
958
+ for (bg_raw, kind), ps in kept:
959
+ rep = max(ps, key=lambda p: len(p['texts']))
960
+ used[kind] += 1
961
+ name = kind if used[kind] == 1 else '%s-%d' % (kind, used[kind])
962
+ # 标题按「位置 + 跨度」认,不按字号——巨号数值(21%、7,869)常比标题还大
963
+ band = [t for t in rep['texts']
964
+ if t['box'].get('y', 1e9) < cH * 0.28 and t['box'].get('w', 0) >= cW * 0.25]
965
+ title = max(band, key=lambda t: t['sz']) if band else (
966
+ max(rep['texts'], key=lambda t: t['sz']) if rep['texts'] else None)
967
+ rest = [t for t in rep['texts'] if t is not title]
968
+ rest.sort(key=lambda t: (t['box'].get('y', 0), t['box'].get('x', 0)))
969
+ ordered = ([title] if title else []) + rest
970
+ slots = []
971
+ for i, t in enumerate(ordered[:6]):
972
+ b = t['box']
973
+ if t is title:
974
+ role = typ = 'title'
975
+ elif (title and i == 1 and t['sz'] >= 28
976
+ and abs(b.get('x', 0) - title['box'].get('x', 0)) < 120
977
+ and 0 <= b.get('y', 0) - (title['box'].get('y', 0)
978
+ + title['box'].get('h', 0)) < 220):
979
+ role = typ = 'subtitle'
980
+ else:
981
+ role = typ = 'body'
982
+ row = {'role': role, 'box': [round(b.get('x', 0)), round(b.get('y', 0)),
983
+ round(b.get('w', 0)), round(b.get('h', 0))],
984
+ 'type': typ, 'sz': t['sz'], 'txt': t['txt']}
985
+ row.update(t.get('style') or {})
986
+ slots.append(row)
987
+ # 代表页上的小图元素按位置去重后落 slots(同一 logo 在不同页型位置不同)
988
+ seen_mark = set()
989
+ for mk in rep.get('marks') or []:
990
+ b = mk['box']
991
+ key = (mk['media'], round(b.get('x', 0)), round(b.get('y', 0)))
992
+ if key in seen_mark:
993
+ continue
994
+ seen_mark.add(key)
995
+ slots.append({'role': 'logo', 'type': 'pic', 'sz': 0, 'txt': '',
996
+ 'media': mk['media'],
997
+ 'box': [round(b.get('x', 0)), round(b.get('y', 0)),
998
+ round(b.get('w', 0)), round(b.get('h', 0))]})
999
+ archetypes.append({'name': name, 'bg': None, 'bg_raw': bg_raw, 'slots': slots,
1000
+ 'pages': sorted(p['no'] for p in ps), 'rep': rep['no'],
1001
+ 'pic_n': rep['pic_n'],
1002
+ 'confidence': 'high' if len(ps) >= 3 else
1003
+ ('medium' if len(ps) == 2 else 'low')})
1004
+ return archetypes, pages, leftover
1005
+
1006
+
1007
+ # ---------------------------------------------------------------- 联系表
1008
+ def layout_sheet(outdir, archetypes, path):
1009
+ """把各 archetype 的代表页光栅出来拼成一张——版式命名得看得见页面。"""
1010
+ use_layout = all(a.get('rep') is None for a in archetypes)
1011
+ reps = [a.get('rep_layout') if use_layout else a.get('rep') for a in archetypes]
1012
+ reps = [x for x in reps if x is not None]
1013
+ if not reps:
1014
+ return None
1015
+ import subprocess
1016
+ r = subprocess.run([sys.executable, os.path.join(HERE, 'render_pages.py'), outdir,
1017
+ '--pages', 'layouts' if use_layout else 'slides',
1018
+ '--only', ','.join(map(str, reps)), '--no-html'],
1019
+ capture_output=True, text=True)
1020
+ png_dir = os.path.join(outdir, 'ref', 'rebuild', 'png')
1021
+ if r.returncode or not os.path.isdir(png_dir):
1022
+ return None
1023
+ try:
1024
+ from PIL import Image, ImageDraw
1025
+ except Exception:
1026
+ return None
1027
+ cols = 2 if len(archetypes) > 1 else 1
1028
+ cw, ch, pad, lab = 480, 270, 16, 20
1029
+ rows = (len(archetypes) + cols - 1) // cols
1030
+ sheet = Image.new('RGB', (cols * (cw + pad) + pad, rows * (ch + pad + lab) + pad),
1031
+ (245, 245, 247))
1032
+ dr = ImageDraw.Draw(sheet)
1033
+ for i, a in enumerate(archetypes):
1034
+ x = pad + (i % cols) * (cw + pad)
1035
+ y = pad + (i // cols) * (ch + pad + lab)
1036
+ no = a.get('rep_layout') if use_layout else a.get('rep')
1037
+ f = os.path.join(png_dir, '%s-%s.png' % ('layout' if use_layout else 'slide', no))
1038
+ if os.path.exists(f):
1039
+ im = Image.open(f).convert('RGB')
1040
+ im.thumbnail((cw, ch))
1041
+ sheet.paste(im, (x, y))
1042
+ dr.rectangle([x, y, x + cw, y + ch], outline=(120, 120, 128))
1043
+ # 标注只写 ASCII——Pillow 默认字体没有 CJK 字形,中文会渲染成方框
1044
+ dr.text((x + 2, y + ch + 5), '[%s] %s bg=%s'
1045
+ % (a['name'],
1046
+ ('layout %s' % a.get('rep_layout')) if use_layout
1047
+ else ('slide %s x%d pages' % (a.get('rep'), len(a['pages']))),
1048
+ a.get('bg') or '-'),
1049
+ fill=(20, 20, 24))
1050
+ sheet.save(path, optimize=True)
1051
+ return path
1052
+
1053
+
1054
+ def contact_sheet(outdir, cands, path):
1055
+ try:
1056
+ from PIL import Image, ImageDraw
1057
+ except Exception:
1058
+ return None
1059
+ cell, pad, cols = 220, 20, 4
1060
+ items = cands[:12]
1061
+ if not items:
1062
+ return None
1063
+ rows = (len(items) + cols - 1) // cols
1064
+ W = cols * (cell + pad) + pad
1065
+ H = rows * (cell + pad + 18) + pad
1066
+ sheet = Image.new('RGB', (W, H), (245, 245, 247))
1067
+ dr = ImageDraw.Draw(sheet)
1068
+ for idx, c in enumerate(items):
1069
+ x = pad + (idx % cols) * (cell + pad)
1070
+ y = pad + (idx // cols) * (cell + pad + 18)
1071
+ # 棋盘格底,透明区看得见
1072
+ for gy in range(0, cell, 16):
1073
+ for gx in range(0, cell, 16):
1074
+ if (gx // 16 + gy // 16) % 2 == 0:
1075
+ dr.rectangle([x + gx, y + gy, x + gx + 15, y + gy + 15], fill=(214, 214, 218))
1076
+ try:
1077
+ im = Image.open(os.path.join(outdir, c['out'])).convert('RGBA')
1078
+ im.thumbnail((cell, cell))
1079
+ sheet.paste(im, (x + (cell - im.width) // 2, y + (cell - im.height) // 2), im)
1080
+ except Exception:
1081
+ dr.text((x + 8, y + 8), 'unreadable', fill=(200, 0, 0))
1082
+ dr.rectangle([x, y, x + cell, y + cell], outline=(120, 120, 128))
1083
+ dr.text((x + 2, y + cell + 4), '[%d] %s %dx%d used=%d'
1084
+ % (idx + 1, c['file'], c['probe'].get('w') or 0, c['probe'].get('h') or 0, c['n']),
1085
+ fill=(20, 20, 24))
1086
+ sheet.save(path, optimize=True)
1087
+ return path
1088
+
1089
+
1090
+ # ---------------------------------------------------------------- 落盘
1091
+ def write(p, s):
1092
+ with open(p, 'w', encoding='utf-8') as f:
1093
+ f.write(s)
1094
+
1095
+
1096
+ def emit_manifest(d, assets, ldir):
1097
+ L = ['version: alpha',
1098
+ 'name: TODO-style-name # 英文 kebab,体现气质,不要用文件名',
1099
+ 'name_zh: TODO中文名',
1100
+ 'description: >',
1101
+ ' TODO: 一句话说清这套模板的视觉性格(底色 / 主色 / 字形 / 版面骨架),给消费模型定调。']
1102
+ themes = d['theme_topology'].get('themes') or ['single']
1103
+ if themes != ['single'] and len(themes) > 1:
1104
+ L += ['themes: [%s]' % ', '.join(themes), 'default-theme: %s' % themes[0]]
1105
+ if assets:
1106
+ L.append('assets:')
1107
+ for a in assets:
1108
+ L.append(' - id: %s' % a['id'])
1109
+ L.append(' source_media: %s' % a['src']['file'])
1110
+ L.append(' kind: %s' % a['kind'])
1111
+ if a['role']:
1112
+ L.append(' role: %s' % a['role'])
1113
+ if a['kind'] in ('logo', 'slogan'):
1114
+ L.append(' on-bg: %s' % (a.get('on_bg') or 'light'))
1115
+ if a['use_full']:
1116
+ L.append(' use_full: true')
1117
+ write(os.path.join(ldir, 'manifest.yaml'), '\n'.join(L) + '\n')
1118
+
1119
+
1120
+ def emit_frontmatter(d, tokens, fonts, roles, anchors, gaps, ldir):
1121
+ L = ['colors:']
1122
+ for name, r in tokens:
1123
+ L.append(' %s: "%s"' % (name, r['hex']))
1124
+ body_font = fonts[1] if len(fonts) > 1 else (fonts[0] if fonts else None)
1125
+ disp_font = fonts[0] if fonts else None
1126
+ if disp_font:
1127
+ L.append('typography:')
1128
+ L.append(" fontFamily: '%s'" % font_css(disp_font['stack']))
1129
+ if body_font and body_font is not disp_font:
1130
+ L.append(" bodyFontFamily: '%s'" % font_css(body_font['stack']))
1131
+ for role, t in roles.items():
1132
+ lh = lh_of(t)
1133
+ L.append(' %s: {fontSize: %dpx%s}' % (
1134
+ role, round(t['sz_px']), ', lineHeight: %s' % lh if lh else ''))
1135
+ sp = d.get('spacing_candidates') or {}
1136
+ pads = sp.get('paddings') or []
1137
+ edge = {}
1138
+ for p in pads:
1139
+ edge.setdefault(p['edge'], p['px'])
1140
+ if edge:
1141
+ L.append('spacing:')
1142
+ L.append(' page-padding: {top: %s, right: %s, bottom: %s, left: %s}'
1143
+ % (edge.get('top', 73), edge.get('right', 90),
1144
+ edge.get('bottom', 95), edge.get('left', 90)))
1145
+ radii = [r for r in (d.get('radii_census') or []) if r['px'] >= 2 and r['n'] >= 6]
1146
+ if radii:
1147
+ top = max(radii, key=lambda r: r['n'])
1148
+ L.append('rounded:')
1149
+ L.append(' card: %dpx' % round(top['px']))
1150
+ L.append('safe-area:')
1151
+ L.append(' content: {top: %s, right: %s, bottom: %s, left: %s, applies-to: [content]}'
1152
+ % (edge.get('top', 73), edge.get('right', 90), edge.get('bottom', 95), edge.get('left', 90)))
1153
+ L.append(' confidence: medium')
1154
+ L.append('anchors:')
1155
+ for aid, typ, desc in anchors:
1156
+ L.append(' - {id: %s, type: %s, desc: "%s"}' % (aid, typ, desc))
1157
+ L.append('gaps:')
1158
+ for g in gaps:
1159
+ L.append(' - "%s"' % g)
1160
+ write(os.path.join(ldir, 'frontmatter.yaml'), '\n'.join(L) + '\n')
1161
+
1162
+
1163
+ def emit_layouts(archetypes, ldir):
1164
+ prefilled = sum(1 for a in archetypes if a.get('zh'))
1165
+ L = ['# 只改 names 和 bg_rules 两段(都是扁平键值,改完 package.py 自动并回各页型)。',
1166
+ '# 下面 layouts 段是普查数值,一个字都不要动——改它容易连带删掉 slots/confidence。']
1167
+ if prefilled:
1168
+ L.append('# names 已按模板自带的版式名填好 %d 条,读一遍确认表意即可,通常不用改。' % prefilled)
1169
+ L.append('names:')
1170
+ for a in archetypes:
1171
+ if a.get('zh'):
1172
+ # 模板自己给版式起了名(form=3),直接用——比看图起名准,也省掉一轮判断
1173
+ L.append(' %s: %s' % (a['name'], q(a['zh'])))
1174
+ else:
1175
+ L.append(' %s: TODO中文名(代表页 %s,共 %d 页)'
1176
+ % (a['name'], a['rep'], len(a['pages'])))
1177
+ # 禁放区是**背景图**的属性,不是页型的属性——按背景资产分组,页型再多也不涨
1178
+ bgs = []
1179
+ for a in archetypes:
1180
+ if a['bg'] and a['bg'] not in bgs:
1181
+ bgs.append(a['bg'])
1182
+ if bgs:
1183
+ L.append('bg_rules:')
1184
+ for bg in bgs:
1185
+ users = [a['name'] for a in archetypes if a['bg'] == bg]
1186
+ L.append(' %s: # 用它的页型:%s' % (bg, ', '.join(users)))
1187
+ L.append(' text_safe: TODO安全文字区[x,y,w,h],按这张背景的主体避让后填写')
1188
+ L.append(' avoid: TODO禁放区列表;无禁放区写 [],有则写 [{box: [x,y,w,h], reason: "..."}]')
1189
+ L.append(' pairing_rule: "TODO这张背景上标题/正文/图表要避让哪些区域"')
1190
+ L.append('layouts:')
1191
+ for a in archetypes:
1192
+ L.append(' %s:' % a['name'])
1193
+ L.append(' role: %s' % a['name'].split('-')[0])
1194
+ if a['bg']:
1195
+ L.append(' background: %s' % a['bg'])
1196
+ L.append(' slots:')
1197
+ for s in a['slots']:
1198
+ extra = ''
1199
+ if s.get('asset'):
1200
+ extra += ', asset: %s' % s['asset']
1201
+ for k in ('size', 'weight', 'color', 'align', 'valign'):
1202
+ if s.get(k) is not None:
1203
+ v = s[k]
1204
+ extra += ', %s: %s' % (k, '"%s"' % v if k == 'color' else v)
1205
+ L.append(' - {role: %s, box: %s, type: %s%s}'
1206
+ % (s['role'], s['box'], s['type'], extra))
1207
+ L.append(' confidence: %s' % a.get('confidence', 'medium'))
1208
+ write(os.path.join(ldir, 'layouts.yaml'), '\n'.join(L) + '\n')
1209
+
1210
+
1211
+ def emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, ldir):
1212
+ """design.md 正文。
1213
+
1214
+ 每条规则只出现一次——旧版把同一条在 Fast Path / Usage / Background Safety /
1215
+ Hard Rules 各写一遍(实测「内容页背景按 background 取」出现 7 次、「颜色只用
1216
+ colors」6 次),措辞还都不一样,消费端无法判断哪份权威。
1217
+ 坐标、字号、色值、资产位置的权威都在 layouts.md;本文件只给色板、字体栈与纪律。
1218
+ """
1219
+ canvas = d['canvas']['px']
1220
+ cover = next((a for a in assets if a['id'] == 'bg-cover'), None)
1221
+ logo = next((a for a in assets if a['kind'] == 'logo'), None)
1222
+ imp, webs = import_line(fonts)
1223
+ sidecar = '`layouts.md`'
1224
+
1225
+ L = ['## Overview', '',
1226
+ 'TODO: 两三句话讲清这套模板的性格与适用场景——看过联系表和页面重建图之后再写。', '']
1227
+ L.append(('模板自带 %d 种版式,页型、坐标、字号、色值都直读自版式层。'
1228
+ % len(archetypes)) if (d.get('form_hint') or {}).get('form') == 3 else
1229
+ ('%d 页样张归纳出 %d 种页型。' % (d['counts']['slides'], len(archetypes))))
1230
+ L += ['', '## Usage', '',
1231
+ '搭一页 PPT 三步,前两步的数据都在 %s:' % sidecar, '']
1232
+ L += ['1. **挑页型** —— 在 %s 里按用途选一个 archetype(清单见下面 Layouts 段)。'
1233
+ '页数多于页型时,挑最接近的一个原样套用它的 slot,多出来的槽删掉。' % sidecar,
1234
+ '2. **按 slot 落元素** —— 每个 slot 渲染成一个绝对定位元素:`box` 是 '
1235
+ '`[x, y, w, h]`(%dx%d 画布上的绝对像素),字号取 slot 的 `size`,'
1236
+ '字重取 `weight`,颜色取 `color`,对齐取 `align` / `valign`。'
1237
+ '带 `asset` 的 slot 是图片元素(logo、角标),把该资产放在它自己的 `box` 里;'
1238
+ '这个页型没有 `asset` 槽,这一页就不出现该资产。' % (canvas[0], canvas[1]),
1239
+ '3. **配色与字体** —— 色板见下面 Colors 段,字体栈与 `@import` 见 Typography 段。']
1240
+ if assets:
1241
+ L += ['', '资产文件(背景由页型的 `background` 字段指定,'
1242
+ '图片资产的位置由该页型 `slots` 里带 `asset` 的槽给出):', '',
1243
+ '{{ASSET_TABLE}}']
1244
+ L += ['', '文字与容器的外接矩形落在该页型 `background` 对应的 `text_safe` 内,'
1245
+ '避开 `avoid` 列出的区域(两者都在 %s 的 `backgrounds` 段)。内容装不下时换页型或拆页。'
1246
+ % sidecar, '',
1247
+ '## Colors', '', '| token | 值 | 用途 |', '|---|---|---|']
1248
+ for name, r in tokens:
1249
+ L.append('| `%s` | `%s` | %s |'
1250
+ % (name, r['hex'], usage_phrase(cusage.get(r['hex'].upper()))))
1251
+ L += ['', '## Typography', '']
1252
+ for f in fonts[:2]:
1253
+ L.append('- **%s** —— 栈 `%s`%s' % (
1254
+ f['names'][0], font_css(f['stack']),
1255
+ ',源为商业/内部字体无 web 分发源,按气质降级到 %s' % f['stack'][1]
1256
+ if len(f['stack']) > 1 else ''))
1257
+ L += ['', '字号轴:' + '、'.join('%s %dpx' % (k, round(v['sz_px'])) for k, v in roles.items())
1258
+ + '。slot 自带 `size` 时以 slot 为准;层级在轴上没有的,复用最接近的一档。', '',
1259
+ '字体加载(**HARD REQUIREMENT:下面这行 @import 原样写入全局样式首行,禁止替换为 '
1260
+ 'fonts.googleapis.com 或其他域**):', '', '```', imp, '```', '',
1261
+ '镜像只保证 wght 400 一档,更粗的字重由浏览器合成,字重不能作为唯一区分手段;'
1262
+ '系统字体 PingFang SC / Microsoft YaHei 置于栈末保底,中文场景负字距清零。', '',
1263
+ '## Layouts', '', '页型清单如下,每个页型的 slots、background、'
1264
+ '禁放区都在 %s:' % sidecar, '', '{{LAYOUT_LIST}}',
1265
+ '', '## Hard Rules', '']
1266
+ if cover:
1267
+ L.append('- 封面页铺满 `bg-cover`,整幅覆盖 %dx%d 画布。' % (canvas[0], canvas[1]))
1268
+ if any(a['role'] == 'content' for a in assets):
1269
+ L.append('- 内容页的背景由该页型的 `background` 字段指定,整幅铺满。')
1270
+ if logo:
1271
+ L.append('- `%s` 的位置来自各页型 `slots` 里 `role: logo` 的 `box`——原样使用该文件,'
1272
+ '保持原比例。' % logo['id'])
1273
+ L += ['- 坐标、字号、色值、资产位置以 %s 为准;本文件的 Colors / Typography 是可用值的清单。'
1274
+ % sidecar,
1275
+ '- 内容语义色(增长绿、下降红之类)本模板没有:用色板内颜色的深浅或透明度表达正负。',
1276
+ '- 本包里的数值就是普查结果,照用即可,无需重新统计颜色、字体或版式。',
1277
+ '- 风格包以文本形式(zip 摘要等)到手时,直接用摘要里 design.md / layouts.md 的文本。',
1278
+ '- TODO: 补 1-2 条这套模板特有的硬规则(看过重建图之后写,例如主色只许用在哪类元素)。',
1279
+ '', '## Exceptions', '']
1280
+ if exceptions:
1281
+ L += ['- ' + e for e in exceptions]
1282
+ else:
1283
+ L.append('- 无额外例外:所有页型都遵守上面的安全区与色板纪律。')
1284
+ L.append('')
1285
+ write(os.path.join(ldir, 'body.md'), '\n'.join(L) + '\n')
1286
+
1287
+
1288
+ def emit_brief(d, ctx, ldir):
1289
+ (tokens, rest, fonts, roles, assets, rejected, todos, archetypes, cands, sheet,
1290
+ leftover, lsheet) = ctx
1291
+ canvas = d['canvas']['px']
1292
+ L = ['# 抽取简报(草案已生成,读完这一页就能改)', '',
1293
+ '源:`%s` 画布 %dx%d %d 页 / %d 版式 主题 %s form=%s'
1294
+ % (d['source']['filename'], canvas[0], canvas[1], d['counts']['slides'],
1295
+ d['counts']['layouts'], d['theme_topology']['themes'],
1296
+ d['form_hint']['form']), '',
1297
+ '## 待判断(草案里已标 TODO,逐条改掉)', '']
1298
+ base_todos = ['给风格起名:`manifest.yaml` 的 name / name_zh / description(看两张图定气质)',
1299
+ '`layouts.yaml` 顶部 `names:` 一段填 %d 个中文页型名(看 layout-sheet.png,'
1300
+ '一次改完;下面 layouts 段不要动)' % len(archetypes),
1301
+ '`body.md` 的 Overview 与 Hard Rules 末条(Colors 用途列草案已填好,觉得不对再改)']
1302
+ for t in base_todos + todos:
1303
+ L.append('- ' + t)
1304
+ L += ['', '## 联系表(一次看完所有候选图)', '',
1305
+ '`l-out/contact-sheet.png` —— 编号对应下表;看完再决定 logo / 封面归属。' if sheet
1306
+ else '(Pillow 不可用,未生成联系表;逐张看 `media-out/`)', '',
1307
+ '| # | 文件 | 尺寸 | 出现 | 满屏 | 页 | 草案判定 |', '|---|---|---|---|---|---|---|']
1308
+ decided = {a['src']['file']: a['id'] for a in assets}
1309
+ why = {c['file']: r for c, r in rejected}
1310
+ for i, c in enumerate(cands[:12], 1):
1311
+ L.append('| %d | `%s` | %sx%s | %d | %s | %s | %s |' % (
1312
+ i, c['file'], c['probe'].get('w') or '?', c['probe'].get('h') or '?', c['n'],
1313
+ 'Y' if c['fullscreen'] else '', ','.join(map(str, c['slides'][:6])) or 'layout',
1314
+ decided.get(c['file']) or ('✗ ' + why.get(c['file'], '未采纳'))))
1315
+ L += ['', '## 颜色(草案 token 已写进 frontmatter.yaml)', '',
1316
+ '| token | hex | 出现 |', '|---|---|---|']
1317
+ for name, r in tokens:
1318
+ L.append('| `%s` | %s | %d |' % (name, r['hex'], r['n']))
1319
+ if rest:
1320
+ L.append('')
1321
+ L.append('未取用高频色:' + '、'.join('%s(%d)' % (r['hex'], r['n']) for r in rest))
1322
+ L += ['', '## 字体 / 字号', '']
1323
+ for f in fonts:
1324
+ L.append('- `%s` 渲染 %d 处,字重 %s → 降级链 `%s`%s' % (
1325
+ f['names'][0], f['rendered'], f['weights'], ' > '.join(f['stack']),
1326
+ '(映射表命中 %s)' % f['mapped'] if f['mapped'] else '(映射表未命中,已留原名)'))
1327
+ L.append('')
1328
+ L.append('字号轴:' + '、'.join('%s=%dpx(n=%d)' % (k, round(v['sz_px']), v['n'])
1329
+ for k, v in roles.items()))
1330
+ L += ['', '## 版式聚类(草案已写进 layouts.yaml)', '',
1331
+ '`l-out/layout-sheet.png` 是各页型代表页的重建图——**看它给页型起名**,'
1332
+ '不用再逐页查 shapes。' if lsheet else '(未生成版式图,按下面的 slot 原文命名)', '',
1333
+ '| archetype | 页数 | 代表页 | 背景 | slot 数 |', '|---|---|---|---|---|']
1334
+ for a in archetypes:
1335
+ L.append('| `%s` | %d | %s | %s | %d |' % (
1336
+ a['name'], len(a['pages']), a['rep'], a['bg'] or '(无资产底图)', len(a['slots'])))
1337
+ if leftover:
1338
+ L += ['', '未归入 archetype 的页:%s —— 都是单页孤例,需要就自己补一个 archetype。'
1339
+ % ', '.join(map(str, leftover))]
1340
+ L += ['', '各 archetype 的 slot 原文(据此起中文页型名、改 role):', '']
1341
+ for a in archetypes:
1342
+ L.append('- `%s`(第 %s 页,覆盖 %s)' % (a['name'], a['rep'], a['pages']))
1343
+ for s in a['slots']:
1344
+ L.append(' - %s %spx 「%s」' % (s['role'], round(s['sz']), s['txt']))
1345
+ L += ['', '## 下一步', '',
1346
+ '1. 看 `contact-sheet.png` 和 `layout-sheet.png`;'
1347
+ '2. 用一次批量编辑/patch 改掉四份草案里的 TODO;3. 跑 `package.py`。']
1348
+ write(os.path.join(ldir, 'BRIEF.md'), '\n'.join(L) + '\n')
1349
+
1350
+
1351
+ def main(argv=None):
1352
+ ap = argparse.ArgumentParser()
1353
+ ap.add_argument('outdir')
1354
+ a = ap.parse_args(argv)
1355
+ outdir = os.path.abspath(a.outdir)
1356
+ d = json.load(open(os.path.join(outdir, 'extract.json'), encoding='utf-8'))
1357
+ ldir = os.path.join(outdir, 'l-out')
1358
+ os.makedirs(ldir, exist_ok=True)
1359
+
1360
+ all_shapes = json.load(open(os.path.join(outdir, 'ref', 'shapes.json'),
1361
+ encoding='utf-8'))['shapes']
1362
+ cusage = color_usage(all_shapes, d)
1363
+ tokens, rest, rows = draft_colors(d, cusage)
1364
+ fonts = draft_fonts(d)
1365
+ archetypes, pages, leftover = draft_layouts(d, outdir)
1366
+ cover_media = next((a['bg_raw'] for a in archetypes if a['name'] == 'cover'), None)
1367
+ bg_needed = {a['bg_raw'] for a in archetypes if a['bg_raw'] and a['bg_raw'].startswith('ppt/media')}
1368
+ bg_under = {p['no']: p['bg_media'] for p in pages}
1369
+ assets, rejected, todos, alias = draft_assets(d, outdir, bg_needed, cover_media, bg_under)
1370
+ media_to_asset = {a['src']['media']: a['id'] for a in assets}
1371
+ for m, w in (alias or {}).items():
1372
+ if w in media_to_asset:
1373
+ media_to_asset.setdefault(m, media_to_asset[w])
1374
+ for a in archetypes:
1375
+ a['bg'] = media_to_asset.get(a['bg_raw'])
1376
+ # 版式自带的图片元素:映射到资产 id;映射不到就不写(宁缺勿指空)
1377
+ keep = []
1378
+ for s in a['slots']:
1379
+ if s.get('media'):
1380
+ aid = media_to_asset.get(s['media'])
1381
+ if not aid:
1382
+ continue
1383
+ s['asset'] = aid
1384
+ keep.append(s)
1385
+ a['slots'] = keep
1386
+ roles = draft_scale(d, archetypes)
1387
+ slot_added = cover_slot_colors(tokens, archetypes, rows, cusage)
1388
+ cands = sorted([c for c in [a['src'] for a in assets]] +
1389
+ [c for c, _ in rejected], key=lambda c: (-c['n'], c['file']))
1390
+ sheet = contact_sheet(outdir, cands, os.path.join(ldir, 'contact-sheet.png'))
1391
+ lsheet = layout_sheet(outdir, archetypes, os.path.join(ldir, 'layout-sheet.png'))
1392
+
1393
+ anchors = draft_anchors(d, tokens, fonts, roles, assets, archetypes)
1394
+ gaps, exceptions = [], []
1395
+ for c, why in rejected:
1396
+ if '近全透明' in why:
1397
+ gaps.append('母版/版式里的 %s 是%s,不是设计资产,任何情况下不要当背景用。' % (c['file'], why))
1398
+ for f in fonts[:2]:
1399
+ if len(f['stack']) > 1:
1400
+ gaps.append('源字体 %s 无 web 授权源,已按 font-fallback 表降级到 %s;字形细节与原稿有差异。'
1401
+ % (f['names'][0], f['stack'][1]))
1402
+ nosize = [(a['name'], s['box']) for a in archetypes for s in a['slots']
1403
+ if not s.get('asset') and not s.get('size')]
1404
+ if nosize:
1405
+ gaps.append('这些文字槽在源文件任何层级都没有字号声明(都不是占位符,是普通文本框,'
1406
+ '继承源是 presentation.xml 的 defaultTextStyle,本抽取按约定不解继承链):'
1407
+ '%s。用 typography 里最接近的档位,不要自造新档。'
1408
+ % '、'.join('%s %s' % (n, b) for n, b in nosize[:6]))
1409
+
1410
+ if leftover:
1411
+ exceptions.append('源 deck 第 %s 页是单页孤例,没有归纳成 archetype;需要类似构图时按最接近的页型改。'
1412
+ % '、'.join(map(str, leftover)))
1413
+
1414
+ emit_manifest(d, assets, ldir)
1415
+ emit_frontmatter(d, tokens, fonts, roles, anchors, gaps, ldir)
1416
+ emit_layouts(archetypes, ldir)
1417
+ emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, ldir)
1418
+ emit_brief(d, (tokens, rest, fonts, roles, assets, rejected, todos, archetypes, cands, sheet,
1419
+ leftover, lsheet), ldir)
1420
+
1421
+ print('草案就绪 -> %s' % ldir)
1422
+ print(' 资产 %d(%s) 版式 %d 色 %d 字体 %d'
1423
+ % (len(assets), ', '.join(x['id'] for x in assets), len(archetypes), len(tokens), len(fonts)))
1424
+ print(' 先读 l-out/BRIEF.md,再看 l-out/contact-sheet.png')
1425
+ return 0
1426
+
1427
+
1428
+ if __name__ == '__main__':
1429
+ sys.exit(main())