@lark-apaas/coding-steering 0.1.18-dev.2bb478f → 0.1.18-dev.40f9427

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