@lark-apaas/coding-steering 0.1.18-dev.21ea0ba → 0.1.18-dev.28c4f05

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.
@@ -19,6 +19,14 @@ import shutil
19
19
  import sys
20
20
  from collections import Counter, defaultdict
21
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
+
22
30
  HERE = os.path.dirname(os.path.abspath(__file__))
23
31
  SKILL_ROOT = os.path.dirname(HERE)
24
32
  SYS_FALLBACK = '"PingFang SC", "Microsoft YaHei", sans-serif'
@@ -82,7 +90,64 @@ def q(v):
82
90
 
83
91
 
84
92
  # ---------------------------------------------------------------- 颜色
85
- def draft_colors(d):
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 {}
86
151
  pool = [c for c in d['color_freq']
87
152
  if c.get('class') == 'design' and abs((c.get('alpha') or 100) - 100) < 0.1]
88
153
  seen, rows = set(), []
@@ -92,29 +157,61 @@ def draft_colors(d):
92
157
  continue
93
158
  seen.add(h)
94
159
  rgb = hex2rgb(h)
95
- rows.append({'hex': h, 'n': c['n'], 'lum': lum(rgb), 'sat': satu(rgb)})
96
- rows.sort(key=lambda r: -r['n'])
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)
97
170
 
98
171
  tokens, used = [], set()
99
172
 
100
173
  def take(pred, names):
101
174
  for name in names:
102
- for r in rows:
175
+ for r in strong:
103
176
  if r['hex'] in used or not pred(r):
104
177
  continue
105
178
  used.add(r['hex'])
106
179
  tokens.append((name, r))
107
180
  break
108
181
 
109
- take(lambda r: r['lum'] > 0.85 and r['sat'] < 0.15, ['surface', 'surface-alt'])
110
- take(lambda r: r['lum'] < 0.32 and r['sat'] < 0.25, ['ink', 'ink-muted'])
111
- take(lambda r: r['sat'] >= 0.35, ['primary', 'accent', 'accent-2', 'accent-3'])
112
- take(lambda r: r['sat'] < 0.35, ['neutral'])
113
- # 未取用但高频的留给 BRIEF 展示
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'])
114
210
  rest = [r for r in rows if r['hex'] not in used][:6]
115
211
  return tokens, rest, rows
116
212
 
117
213
 
214
+
118
215
  # ---------------------------------------------------------------- 字体
119
216
  def parse_fallback_table():
120
217
  path = os.path.join(SKILL_ROOT, 'font-fallback.yaml')
@@ -142,6 +239,48 @@ def norm(s):
142
239
  return re.sub(r'[\s\-_]', '', s or '').lower()
143
240
 
144
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
+
145
284
  def draft_fonts(d):
146
285
  table = parse_fallback_table()
147
286
  groups = defaultdict(lambda: {'rendered': 0, 'weights': set(), 'names': set(), 'bold': 0})
@@ -201,35 +340,184 @@ def import_line(fonts):
201
340
  return "@import url('%s?%s&display=swap');" % (MIRROR, fam), webs
202
341
 
203
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
+
204
415
  def draft_anchors(d, tokens, fonts, roles, assets, archetypes):
205
- names = [t[0] for t in tokens]
416
+ """anchors 逐条由统计覆盖率产出;证据不足就不生成这一条,不用形容词补。"""
417
+ A = []
418
+ n_arch = len(archetypes) or 1
419
+
420
+ # 1. 表达色:未取用的高频彩色要如实带上,不能说「其余全是中性」
421
+ names = [x[0] for x in tokens]
206
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. 圆角:按普查占比
207
428
  radii = d.get('radii_census') or []
208
429
  zero = next((r for r in radii if r['px'] == 0), None)
209
- total_r = sum(r['n'] for r in radii) or 1
210
- geom = d.get('geom_census') or {}
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. 阴影:只在描边极少时才敢说「不用描边分隔」
211
478
  eff = d.get('effects_census') or {}
212
- A = []
213
- if chroma:
214
- A.append((chroma[0] + '-led-palette', 'token',
215
- '表达色只有 %s 这一组,其余全是中性底与墨色,见 colors' % '、'.join(chroma[:3])))
216
- if zero and zero['n'] >= total_r * 0.7:
217
- A.append(('zero-radius', 'token', '卡片、按钮、面板一律直角,圆角量在全 deck 压倒性为零'))
218
- if any(a['kind'] == 'background' for a in assets):
219
- A.append(('full-bleed-ground', 'pattern', '每页由整幅铺满的底图打底,元素浮在图上而不是浮在纯色块上'))
220
- if any(a['kind'] == 'logo' for a in assets):
221
- A.append(('corner-locked-logo', 'component', '品牌标识固定在同一角位,跨页不移动、不缩放'))
222
- if geom.get('gradient_fills'):
223
- A.append(('gradient-accent', 'pattern', '强调元素靠线性渐变承载,而不是纯色块'))
224
- if len(fonts) >= 2:
225
- A.append(('dual-family-typesetting', 'token', '中文与拉丁数字分属两套字族,同一行里混排'))
226
479
  if eff.get('outerShdw'):
227
- A.append(('soft-shadow-card', 'component', '卡片靠极浅外阴影托起,不用描边分隔'))
228
- disp, body = roles.get('display'), roles.get('body')
229
- if disp and body and disp['sz_px'] >= body['sz_px'] * 3:
230
- A.append(('size-driven-hierarchy', 'pattern', '层级靠字号跨度拉开而不是字重,展示档与正文档差出数倍'))
231
- A.append(('archetype-reuse', 'pattern', '全 deck 只用 %d 种页型反复排列,版面骨架高度复用' % len(archetypes)))
232
- A.append(('safe-area-discipline', 'token', '正文一律落在统一安全区内,不贴画布边'))
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
+
233
521
  seen, out = set(), []
234
522
  for a in A:
235
523
  if a[0] in seen:
@@ -238,8 +526,6 @@ def draft_anchors(d, tokens, fonts, roles, assets, archetypes):
238
526
  out.append(a)
239
527
  return out[:8]
240
528
 
241
-
242
- # ---------------------------------------------------------------- 字号轴
243
529
  def draft_scale(d, archetypes=()):
244
530
  ts = [t for t in d['text_scale'] if t['sz_px'] >= 10]
245
531
  ts.sort(key=lambda t: -t['sz_px'])
@@ -251,8 +537,9 @@ def draft_scale(d, archetypes=()):
251
537
  display = by_px.get(max(title_sz)) if title_sz else None
252
538
  big = [t for t in ts if t['n'] >= 2] or ts
253
539
  display = display or big[0]
254
- body_pool = [t for t in ts if t['sz_px'] <= 48]
255
- body = max(body_pool, key=lambda t: t['n']) if body_pool else ts[-1]
540
+ # 正文档 = 渲染次数最多的那一档。不设「多大算正文」的上限:大字号排版的模板
541
+ # 正文本来就可能比别的模板的标题还大,预设上限会把它整档判错。
542
+ body = max([t for t in ts if t is not display] or ts, key=lambda t: t['n'])
256
543
  heading_pool = [t for t in ts if body['sz_px'] * 1.3 <= t['sz_px'] < display['sz_px']]
257
544
  heading = max(heading_pool, key=lambda t: t['n']) if heading_pool else None
258
545
  small_pool = [t for t in ts if t['sz_px'] < body['sz_px']]
@@ -347,6 +634,7 @@ def draft_assets(d, outdir, bg_needed=(), cover_media=None, bg_under=None):
347
634
  cands.append({
348
635
  'media': m['media'], 'file': os.path.basename(out_rel), 'out': out_rel,
349
636
  'bytes': m.get('bytes'), 'n': img.get('n', m.get('used_n', 0)),
637
+ 'has_compressed': bool(m.get('compressed_out')),
350
638
  'fullscreen': bool(img.get('fullscreen')), 'w_pct': img.get('max_w_pct', 0),
351
639
  'box': top.get('box') or {}, 'slides': slides,
352
640
  'layer_only': bool(parts) and not slides,
@@ -362,12 +650,21 @@ def draft_assets(d, outdir, bg_needed=(), cover_media=None, bg_under=None):
362
650
  if k not in best_of or c['n'] > best_of[k]['n']:
363
651
  best_of[k] = c
364
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 ())}
365
661
 
366
662
  assets, rejected, todos = [], [], []
663
+ over_cap_bgs = []
367
664
  logo_pool = []
368
665
  bg_under = bg_under or {}
369
666
  bg_i = 0
370
- canvas_w = d['canvas']['px'][0]
667
+ canvas_w, canvas_h = d['canvas']['px']
371
668
  for c in kept:
372
669
  if c['probe'].get('near_blank'):
373
670
  rejected.append((c, '近全透明(alpha 均值 %.0f/255),PPT 里看不见' % c['probe']['alpha_mean']))
@@ -375,22 +672,27 @@ def draft_assets(d, outdir, bg_needed=(), cover_media=None, bg_under=None):
375
672
  if c['fullscreen']:
376
673
  if c['media'] == cover_media:
377
674
  assets.append({'id': 'bg-cover', 'kind': 'background', 'role': 'cover',
378
- 'src': c, 'use_full': True})
379
- elif c['media'] in bg_needed and bg_i < 5:
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:
380
680
  bg_i += 1
381
681
  assets.append({'id': 'bg-content-%d' % bg_i, 'kind': 'background',
382
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))
383
686
  else:
384
687
  rejected.append((c, '满屏图但没有页面以它为主底(只在版式层备用)'))
385
- elif c['w_pct'] < 30 and c['n'] >= 2:
688
+ elif c['w_pct'] < SMALL_IMG_W_PCT and c['n'] >= REPEAT_MIN:
689
+ # 品牌标识的共性是「小、重复出现、贴角」。这里只按贴角程度排序给出首选,
690
+ # 不设及格线——「多少分算 logo」没有客观依据,判断交 L 层,分项证据随 TODO 给出。
386
691
  b = c['box']
387
- score = 0
388
- score += 3 if b.get('y', 999) < 160 else (1 if b.get('y', 0) > canvas_w * 0.5 else 0)
389
- score += 2 if b.get('x', 999) < 200 or b.get('x', 0) > canvas_w * 0.7 else 0
390
- ar = (b.get('w') or 1) / max(b.get('h') or 1, 1)
391
- score += 1 if 1.0 <= ar <= 8.0 else 0
392
- score += 1 if c['n'] >= 2 else 0
393
- logo_pool.append((score, c))
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))
394
696
  else:
395
697
  rejected.append((c, '内容区图片(占宽 %.0f%%,出现 %d 次)' % (c['w_pct'], c['n'])))
396
698
 
@@ -404,42 +706,358 @@ def draft_assets(d, outdir, bg_needed=(), cover_media=None, bg_under=None):
404
706
  from PIL import Image
405
707
  im = Image.open(os.path.join(outdir, row['out'])).convert('RGB')
406
708
  b = c['box']
407
- sx, sy = im.width / float(canvas_w), im.height / float(d['canvas']['px'][1])
709
+ sx, sy = im.width / float(canvas_w), im.height / float(canvas_h)
408
710
  crop = im.crop((int(b.get('x', 0) * sx), int(b.get('y', 0) * sy),
409
711
  max(int((b.get('x', 0) + b.get('w', 1)) * sx), 1),
410
712
  max(int((b.get('y', 0) + b.get('h', 1)) * sy), 1))).resize((16, 16))
411
713
  raw = crop.tobytes()
412
714
  px = [raw[i:i + 3] for i in range(0, len(raw), 3)]
413
- return 'light' if sum(lum(p) for p in px) / len(px) > 0.55 else 'dark'
715
+ return 'light' if sum(lum(p) for p in px) / len(px) > LUM_MID else 'dark'
414
716
  except Exception:
415
717
  return None
416
718
 
417
- logo_pool.sort(key=lambda kv: (-kv[0], -kv[1]['n']))
418
- for i, (score, c) in enumerate(logo_pool):
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):
419
729
  b = c['box']
420
- if i == 0 and score >= 5:
730
+ if i == 0:
421
731
  assets.append({'id': 'logo-primary', 'kind': 'logo', 'role': None, 'src': c,
422
732
  'use_full': False, 'on_bg': on_bg_of(c)})
423
- todos.append('看联系表确认 `%s`(%.0fx%.0f @ %.0f,%.0f,出现 %d 次)真是品牌 logo;'
733
+ todos.append('看联系表确认 `%s` 真是品牌 logo(%.0fx%.0f @ %.0f,%.0f,出现 %d 次,'
734
+ '离画布边 %.0f%%,是所有小图里最贴角的一张);'
424
735
  '不是就把 manifest 的 logo-primary 换成别的候选或整条删掉'
425
- % (c['file'], b.get('w', 0), b.get('h', 0), b.get('x', 0), b.get('y', 0), c['n']))
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))
426
755
  else:
427
- rejected.append((c, '重复小图(%.0fx%.0f @ %.0f,%.0f),logo 相似度低于首选'
428
- % (b.get('w', 0), b.get('h', 0), b.get('x', 0), b.get('y', 0))))
756
+ est += orig
429
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])))
430
763
  if not any(a['role'] == 'cover' for a in assets):
431
764
  todos.append('没定出封面底图——从联系表挑一张补进 manifest(role: cover),或在 gaps 写明模板无封面主视觉')
432
765
  copy_logo_candidates(outdir, logo_pool)
433
- return assets, rejected, todos
766
+ return assets, rejected, todos, alias, {c['media']: c for c in kept}
434
767
 
435
768
 
436
769
  # ---------------------------------------------------------------- 版式聚类
437
770
  DECOR_MIN = 40.0
438
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
+
439
1053
 
440
1054
  def draft_layouts(d, outdir):
441
1055
  shapes = json.load(open(os.path.join(outdir, 'ref', 'shapes.json'), encoding='utf-8'))['shapes']
442
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, [], []
443
1061
  by_slide = defaultdict(list)
444
1062
  for s in shapes:
445
1063
  if s.get('layer') == 'slide':
@@ -476,30 +1094,40 @@ def draft_layouts(d, outdir):
476
1094
  b = s.get('box') or {}
477
1095
  if b.get('w', 0) < DECOR_MIN or b.get('h', 0) < 16:
478
1096
  continue
479
- texts.append({'sz': shape_sz(s), 'box': b, 'txt': txt})
1097
+ texts.append({'sz': shape_sz(s), 'box': b, 'txt': txt, 'style': slot_style(s)})
480
1098
  texts.sort(key=lambda t: (-t['sz'], t['box'].get('y', 0)))
481
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]
482
1103
  pages.append({'part': part, 'no': slide_no(part), 'bg_media': bg_media,
483
1104
  'bg_color': bg_of_slide.get(part), 'texts': texts, 'pic_n': len(pics),
484
- 'shape_n': len(sh)})
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]
485
1112
 
486
- def kind_of(p):
1113
+ def density_band(p):
487
1114
  n = len(p['texts'])
488
- top = p['texts'][0]['sz'] if p['texts'] else 0
489
- if p['no'] == 1:
490
- return 'cover'
491
- if n <= 3 and top >= 60:
492
- return 'section'
493
- if n >= 8 or p['pic_n'] >= 4:
494
- return 'content-dense'
495
- return 'content'
1115
+ return 0 if n <= q1 else (1 if n <= q2 else 2)
496
1116
 
497
1117
  groups = defaultdict(list)
498
1118
  for p in pages:
499
- groups[(p['bg_media'] or p['bg_color'] or 'none', kind_of(p))].append(p)
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)
500
1125
 
501
1126
  ranked = sorted(groups.items(), key=lambda kv: (-len(kv[1]), kv[1][0]['no']))
502
- kept = [g for g in ranked if len(g[1]) >= 2 or g[0][1] == 'cover'][:8]
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)]
503
1131
  for g in ranked: # 名额没用满就把最大的孤例页也收进来
504
1132
  if len(kept) >= 8:
505
1133
  break
@@ -508,15 +1136,18 @@ def draft_layouts(d, outdir):
508
1136
  leftover = sorted(p['no'] for g in ranked if g not in kept for p in g[1])
509
1137
 
510
1138
  archetypes = []
511
- used = Counter()
512
- for (bg_raw, kind), ps in kept:
1139
+ for gi, ((bg_raw, _band), ps) in enumerate(kept, 1):
513
1140
  rep = max(ps, key=lambda p: len(p['texts']))
514
- used[kind] += 1
515
- name = kind if used[kind] == 1 else '%s-%d' % (kind, used[kind])
516
- # 标题按「位置 + 跨度」认,不按字号——巨号数值(21%、7,869)常比标题还大
517
- band = [t for t in rep['texts']
518
- if t['box'].get('y', 1e9) < cH * 0.28 and t['box'].get('w', 0) >= cW * 0.25]
519
- title = max(band, key=lambda t: t['sz']) if band else (
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 (
520
1151
  max(rep['texts'], key=lambda t: t['sz']) if rep['texts'] else None)
521
1152
  rest = [t for t in rep['texts'] if t is not title]
522
1153
  rest.sort(key=lambda t: (t['box'].get('y', 0), t['box'].get('x', 0)))
@@ -526,17 +1157,36 @@ def draft_layouts(d, outdir):
526
1157
  b = t['box']
527
1158
  if t is title:
528
1159
  role = typ = 'title'
529
- elif (title and i == 1 and t['sz'] >= 28
530
- and abs(b.get('x', 0) - title['box'].get('x', 0)) < 120
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)
531
1164
  and 0 <= b.get('y', 0) - (title['box'].get('y', 0)
532
- + title['box'].get('h', 0)) < 220):
1165
+ + title['box'].get('h', 0))
1166
+ <= title['box'].get('h', 0) * 2):
533
1167
  role = typ = 'subtitle'
534
1168
  else:
535
1169
  role = typ = 'body'
536
- slots.append({'role': role, 'box': [round(b.get('x', 0)), round(b.get('y', 0)),
537
- round(b.get('w', 0)), round(b.get('h', 0))],
538
- 'type': typ, 'sz': t['sz'], 'txt': t['txt']})
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))
539
1188
  archetypes.append({'name': name, 'bg': None, 'bg_raw': bg_raw, 'slots': slots,
1189
+ 'decor': decor,
540
1190
  'pages': sorted(p['no'] for p in ps), 'rep': rep['no'],
541
1191
  'pic_n': rep['pic_n'],
542
1192
  'confidence': 'high' if len(ps) >= 3 else
@@ -547,12 +1197,15 @@ def draft_layouts(d, outdir):
547
1197
  # ---------------------------------------------------------------- 联系表
548
1198
  def layout_sheet(outdir, archetypes, path):
549
1199
  """把各 archetype 的代表页光栅出来拼成一张——版式命名得看得见页面。"""
550
- reps = [a['rep'] for a in archetypes]
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]
551
1203
  if not reps:
552
1204
  return None
553
1205
  import subprocess
554
1206
  r = subprocess.run([sys.executable, os.path.join(HERE, 'render_pages.py'), outdir,
555
- '--pages', 'slides', '--only', ','.join(map(str, reps)), '--no-html'],
1207
+ '--pages', 'layouts' if use_layout else 'slides',
1208
+ '--only', ','.join(map(str, reps)), '--no-html'],
556
1209
  capture_output=True, text=True)
557
1210
  png_dir = os.path.join(outdir, 'ref', 'rebuild', 'png')
558
1211
  if r.returncode or not os.path.isdir(png_dir):
@@ -570,14 +1223,19 @@ def layout_sheet(outdir, archetypes, path):
570
1223
  for i, a in enumerate(archetypes):
571
1224
  x = pad + (i % cols) * (cw + pad)
572
1225
  y = pad + (i // cols) * (ch + pad + lab)
573
- f = os.path.join(png_dir, 'slide-%d.png' % a['rep'])
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))
574
1228
  if os.path.exists(f):
575
1229
  im = Image.open(f).convert('RGB')
576
1230
  im.thumbnail((cw, ch))
577
1231
  sheet.paste(im, (x, y))
578
1232
  dr.rectangle([x, y, x + cw, y + ch], outline=(120, 120, 128))
579
- dr.text((x + 2, y + ch + 5), '[%s] slide %d x%d pages bg=%s'
580
- % (a['name'], a['rep'], len(a['pages']), a.get('bg') or '-'),
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 '-'),
581
1239
  fill=(20, 20, 24))
582
1240
  sheet.save(path, optimize=True)
583
1241
  return path
@@ -669,20 +1327,31 @@ def emit_frontmatter(d, tokens, fonts, roles, anchors, gaps, ldir):
669
1327
  edge = {}
670
1328
  for p in pads:
671
1329
  edge.setdefault(p['edge'], p['px'])
672
- if edge:
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:
673
1334
  L.append('spacing:')
674
1335
  L.append(' page-padding: {top: %s, right: %s, bottom: %s, left: %s}'
675
- % (edge.get('top', 73), edge.get('right', 90),
676
- edge.get('bottom', 95), edge.get('left', 90)))
677
- radii = [r for r in (d.get('radii_census') or []) if r['px'] >= 2 and r['n'] >= 6]
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]
678
1339
  if radii:
679
1340
  top = max(radii, key=lambda r: r['n'])
680
1341
  L.append('rounded:')
681
1342
  L.append(' card: %dpx' % round(top['px']))
682
- L.append('safe-area:')
683
- L.append(' content: {top: %s, right: %s, bottom: %s, left: %s, applies-to: [content]}'
684
- % (edge.get('top', 73), edge.get('right', 90), edge.get('bottom', 95), edge.get('left', 90)))
685
- L.append(' confidence: medium')
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 '一边都没有')]
686
1355
  L.append('anchors:')
687
1356
  for aid, typ, desc in anchors:
688
1357
  L.append(' - {id: %s, type: %s, desc: "%s"}' % (aid, typ, desc))
@@ -693,130 +1362,147 @@ def emit_frontmatter(d, tokens, fonts, roles, anchors, gaps, ldir):
693
1362
 
694
1363
 
695
1364
  def emit_layouts(archetypes, ldir):
696
- L = ['# 只改 names 这一段:给每个页型起表意的中文名(看 layout-sheet.png)。下面 layouts 段不要动。',
697
- 'names:']
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 = []
698
1393
  for a in archetypes:
699
- L.append(' %s: TODO中文名(代表页 %s,共 %d 页)' % (a['name'], a['rep'], len(a['pages'])))
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这张背景上标题/正文/图表要避让哪些区域"')
700
1404
  L.append('layouts:')
701
1405
  for a in archetypes:
702
1406
  L.append(' %s:' % a['name'])
703
- L.append(' role: %s' % a['name'].split('-')[0])
1407
+ if a.get('role'):
1408
+ L.append(' role: %s' % a['role'])
704
1409
  if a['bg']:
705
1410
  L.append(' background: %s' % a['bg'])
706
- L.append(' text_safe: TODO安全文字区[x,y,w,h],按背景主体避让后填写')
707
- L.append(' avoid: TODO禁放区列表;无禁放区写 [],有则写 [{box: [x,y,w,h], reason: "..."}]')
708
- L.append(' pairing_rule: "TODO说明该页型必须配这张背景时,标题/正文/图表需要避让哪些区域"')
709
1411
  L.append(' slots:')
710
1412
  for s in a['slots']:
711
- L.append(' - {role: %s, box: %s, type: %s}' % (s['role'], s['box'], s['type']))
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('"', "'")))
712
1427
  L.append(' confidence: %s' % a.get('confidence', 'medium'))
713
1428
  write(os.path.join(ldir, 'layouts.yaml'), '\n'.join(L) + '\n')
714
1429
 
715
1430
 
716
- def emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, ldir):
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
+ """
717
1438
  canvas = d['canvas']['px']
718
1439
  cover = next((a for a in assets if a['id'] == 'bg-cover'), None)
719
1440
  logo = next((a for a in assets if a['kind'] == 'logo'), None)
720
- content_bgs = [a['id'] for a in assets
721
- if a.get('kind') == 'background' and a.get('role') == 'content']
722
- key_assets = []
723
- for a in assets[:8]:
724
- role = a.get('role') or a.get('kind')
725
- key_assets.append('`%s` -> `%s` (%s)' % (a['id'], a.get('out_rel') or a['id'], role))
726
-
727
- L = ['## Agent Fast Path', '',
728
- '消费本风格时先读这一节;它是给生成 Agent 的短路径,目标是把风格理解控制在 1 分钟内,避免把审计材料重新理解一遍。',
729
- '',
730
- '- **时间预算**:风格导入最多做 1 `read_file design.md`;如需坐标,最多再读 1 `layouts.md`。完成这两步后必须直接开始生成,不要再探索风格包。',
731
- '- **只读入口**:常规生成只需要 `design.md`;需要坐标时再打开 `layouts.md`。',
732
- '- **附件/zip 兜底**:如果当前内容来自 zip 附件的文本摘要,直接使用摘要中 `design.md` / `layouts.md` 的文本;不要尝试修复 zip、解析二进制、搜索附件目录或重建压缩包。',
733
- '- **禁止动作**:不要读取 `ref/color-freq-raw.json`、`ref/font-clusters.json`、`ref/extract.json`、`ref/rebuild/`、`ref/rebuild/png/*`;不要 summarize / view 参考页;不要重新统计颜色、字体或版式。',
734
- '- **信息来源优先级**:本节 > `## Usage` > frontmatter `assets` / `colors` / `typography` > `layouts.md`。除此之外的文件只用于人工审计,不用于生成。',
735
- '- **缺信息时降级**:如果某个细节本节没有写,用 frontmatter token 和最接近的 `layouts.md` archetype 推断;不要打开审计文件补证。',
736
- '- **画布**:所有坐标按 `%dx%d` 绝对像素理解。' % (canvas[0], canvas[1])]
737
- if cover:
738
- L.append('- **封面背景**:优先使用 `%s`;整幅铺满画布,禁止自造渐变替代。' % cover['id'])
739
- if content_bgs:
740
- L.append('- **内容页背景**:按 `layouts.md` archetype `background` 字段取;常用内容背景为 %s。'
741
- % '、'.join('`%s`' % x for x in content_bgs[:4]))
742
- if cover or content_bgs:
743
- L.append('- **背景安全区**:背景图和版式必须配对。按 archetype `background`、`text_safe`、`avoid` 一起放文字和卡片;标题、正文、关键数字、图表、卡片、时间线及其容器的外接矩形都不得压到背景视觉主体、强光斑、深色透明区上,透明容器也不能跨进禁放区。')
744
- if logo:
745
- L.append('- **标识资产**:只使用 `%s`;不得重画、不得改比例。' % logo['id'])
746
- L += ['- **配色与字体**:颜色只取 frontmatter `colors`;字体/字号只取 frontmatter `typography`;内容主题不得引入新色相。',
747
- '- **版式**:优先使用下面内联版式索引;需要更多 slot 时才读 `layouts.md`;不要用 `ref/rebuild/png` 反推坐标。']
748
- if archetypes:
749
- L += ['', '内联版式索引(先用这里,不要为了选页型再读文件):']
750
- for a in archetypes:
751
- parts = ['`%s`' % a['name']]
752
- if a.get('bg'):
753
- parts.append('背景 `%s`' % a['bg'])
754
- slots = []
755
- for s in a['slots'][:4]:
756
- slots.append('%s/%s %s' % (s['role'], s['type'], s['box']))
757
- L.append('- %s:%s' % (' / '.join(parts), ';'.join(slots) or '按最接近用途套用'))
758
- if key_assets:
759
- L += ['', '关键资产:'] + ['- ' + a for a in key_assets]
760
-
761
- L += ['', '## Overview', '',
762
- 'TODO: 两三句话讲清这套模板的性格与适用场景——看过联系表和页面事实之后再写,不要套话。', '',
763
- '画布 %dx%d px,%d 页样张归纳出 %d 种页型;版式坐标全部放在 `layouts.md`。'
764
- % (canvas[0], canvas[1], d['counts']['slides'], len(archetypes)), '',
765
- '## Usage', '',
766
- '搭一页 PPT 就三步:', '',
767
- '1. **挑版式** —— 打开 `layouts.md`,按用途选一个 archetype,`slots[].box` 的 `[x, y, w, h]` 是 %dx%d 画布上的绝对像素,直接照搬,不要自己排版;同时遵守该 archetype 的 `text_safe` 和 `avoid`。'
768
- % (canvas[0], canvas[1])]
769
- if assets:
770
- L.append('2. **铺资产** —— 背景和标识只用 `assets/` 里的文件,见下表;不要自造渐变或重画 logo。背景必须和版式配对,文字必须留在安全区内。')
771
- else:
772
- L.append('2. **铺背景** —— 本包无图片资产,背景用 `colors` 里的底色。')
773
- L += ['3. **填色与字** —— 颜色只从 frontmatter 的 `colors` 取,字号只从 `typography` 取。', '']
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 段。']
774
1467
  if assets:
775
- L += ['{{ASSET_TABLE}}', '']
776
- L += ['## Background Safety', '',
777
- '真实背景不是纯装饰底。填写本包时必须根据 `contact-sheet.png` 和 `layout-sheet.png` 补充每个 archetype 的 `text_safe`、`avoid`、`pairing_rule`;消费时文字、图表、卡片、表格、时间线、标题容器、正文容器和宽透明容器的外接矩形不得与 `avoid` 区域重叠。内容量超出安全区时换页型或拆页,不要扩大文字区域压住背景主体。', '']
778
- L += ['**色板纪律**:整套页面只用 `colors` 里的 %d 个 token,内容主题(咖啡、医疗、金融……)不改变配色;'
779
- '需要强调时用 `primary`,不要引入模板外的色相。' % len(tokens), '',
1468
+ L += ['', '资产文件(背景由页型的 `background` 字段指定,'
1469
+ '图片资产的位置由该页型 `slots` 里带 `asset` 的槽给出):', '',
1470
+ '{{ASSET_TABLE}}']
1471
+ L += ['', '文字与容器的外接矩形落在该页型 `background` 对应的 `text_safe` 内,'
1472
+ '避开 `avoid` 列出的区域(两者都在 %s `backgrounds` 段)。内容装不下时换页型或拆页。'
1473
+ % sidecar, '',
780
1474
  '## Colors', '', '| token | 值 | 用途 |', '|---|---|---|']
781
- USE = {'surface': '页面与卡片主底色', 'surface-alt': '次级底色,分区/强调区块的浅底',
782
- 'ink': '正文与标题文字色', 'ink-muted': '次级文字色,说明与标签',
783
- 'primary': '主强调色:图表主序列、关键数字、行动点',
784
- 'accent': '副强调色,多与 primary 组成渐变',
785
- 'accent-2': '渐变与图表的第二落点色', 'accent-3': '渐变收尾色,用量最少',
786
- 'neutral': '中性弱化色:分隔线、次要标签'}
787
1475
  for name, r in tokens:
788
- L.append('| `%s` | `%s` | %s |' % (name, r['hex'], USE.get(name, '按 token 名对应的角色使用')))
789
- imp, webs = import_line(fonts)
1476
+ L.append('| `%s` | `%s` | %s |'
1477
+ % (name, r['hex'], usage_phrase(cusage.get(r['hex'].upper()))))
790
1478
  L += ['', '## Typography', '']
791
1479
  for f in fonts[:2]:
792
1480
  L.append('- **%s** —— 栈 `%s`%s' % (
793
1481
  f['names'][0], font_css(f['stack']),
794
1482
  ',源为商业/内部字体无 web 分发源,按气质降级到 %s' % f['stack'][1]
795
1483
  if len(f['stack']) > 1 else ''))
796
- L += ['', '字号轴:' + '、'.join('%s %dpx' % (k, round(v['sz_px'])) for k, v in roles.items()), '',
1484
+ L += ['', '字号轴:' + '、'.join('%s %dpx' % (k, round(v['sz_px'])) for k, v in roles.items())
1485
+ + '。slot 自带 `size` 时以 slot 为准;层级在轴上没有的,复用最接近的一档。', '',
797
1486
  '字体加载(**HARD REQUIREMENT:下面这行 @import 原样写入全局样式首行,禁止替换为 '
798
1487
  'fonts.googleapis.com 或其他域**):', '', '```', imp, '```', '',
799
- '镜像只保证 wght 400 一档,更粗的字重由浏览器合成,不要把字重当唯一区分手段;'
800
- '系统字体 PingFang SC / Microsoft YaHei 置于栈末保底,中文场景负字距一律清零。', '',
801
- '## Layouts', '',
802
- '**搭页前先读 `layouts.md`**——%d archetype slot 坐标都在那里,本文件不重复。'
803
- % len(archetypes), '', '{{LAYOUT_LIST}}']
804
- L += ['', '## Hard Rules', '']
1488
+ '镜像只保证 wght 400 一档,更粗的字重由浏览器合成,字重不能作为唯一区分手段;'
1489
+ '系统字体 PingFang SC / Microsoft YaHei 置于栈末保底,中文场景负字距清零。', '',
1490
+ '## Layouts', '', '页型清单如下,每个页型的 slots、background、'
1491
+ '禁放区都在 %s:' % sidecar, '', '{{LAYOUT_LIST}}',
1492
+ '', '## Hard Rules', '']
805
1493
  if cover:
806
- L.append('- 封面页背景必须铺 `bg-cover`(文件见 Usage 表),整幅铺满 %dx%d,不要自造渐变或换图。'
807
- % (canvas[0], canvas[1]))
1494
+ L.append('- 封面页铺满 `bg-cover`,整幅覆盖 %dx%d 画布。' % (canvas[0], canvas[1]))
808
1495
  if any(a['role'] == 'content' for a in assets):
809
- L.append('- 内容页背景整幅铺满,用哪一张按 `layouts.md` 里该 archetype 的 `background` 字段取,不要混用。')
1496
+ L.append('- 内容页的背景由该页型的 `background` 字段指定,整幅铺满。')
810
1497
  if logo:
811
- b, pages = logo['src']['box'], logo['src']['slides']
812
- where = ('只出现在源第 %s 页' % ''.join(map(str, pages))
813
- if pages and len(pages) <= 4 else '每页放一次')
814
- L.append('- `%s` 放在 (%d, %d),尺寸 %dx%d px,%s;不要重画、不要替换成文字、不要改比例。'
815
- % (logo['id'], b.get('x', 0), b.get('y', 0), b.get('w', 0), b.get('h', 0), where))
816
- L += ['- 版式坐标只从 `layouts.md` 的 `slots[].box` 取。',
817
- '- 颜色只用 `colors` 里的 token;字号只用 `typography` 里的档位。',
818
- '- `@import` 行原样写入,禁止替换域名。',
819
- '- TODO: 补 1-2 条这套模板特有的硬规则(看过联系表之后写,例如主色只许用在哪类元素)。',
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 条这套模板特有的硬规则(看过重建图之后写,例如主色只许用在哪类元素)。',
820
1506
  '', '## Exceptions', '']
821
1507
  if exceptions:
822
1508
  L += ['- ' + e for e in exceptions]
@@ -898,17 +1584,68 @@ def main(argv=None):
898
1584
  ldir = os.path.join(outdir, 'l-out')
899
1585
  os.makedirs(ldir, exist_ok=True)
900
1586
 
901
- tokens, rest, _ = draft_colors(d)
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)
902
1591
  fonts = draft_fonts(d)
903
1592
  archetypes, pages, leftover = draft_layouts(d, outdir)
904
1593
  cover_media = next((a['bg_raw'] for a in archetypes if a['name'] == 'cover'), None)
905
1594
  bg_needed = {a['bg_raw'] for a in archetypes if a['bg_raw'] and a['bg_raw'].startswith('ppt/media')}
906
1595
  bg_under = {p['no']: p['bg_media'] for p in pages}
907
- assets, rejected, todos = draft_assets(d, outdir, bg_needed, cover_media, bg_under)
1596
+ assets, rejected, todos, alias, pool = draft_assets(d, outdir, bg_needed, cover_media, bg_under)
908
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 = []
909
1627
  for a in archetypes:
910
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
911
1647
  roles = draft_scale(d, archetypes)
1648
+ slot_added = cover_slot_colors(tokens, archetypes, rows, cusage)
912
1649
  cands = sorted([c for c in [a['src'] for a in assets]] +
913
1650
  [c for c, _ in rejected], key=lambda c: (-c['n'], c['file']))
914
1651
  sheet = contact_sheet(outdir, cands, os.path.join(ldir, 'contact-sheet.png'))
@@ -919,10 +1656,33 @@ def main(argv=None):
919
1656
  for c, why in rejected:
920
1657
  if '近全透明' in why:
921
1658
  gaps.append('母版/版式里的 %s 是%s,不是设计资产,任何情况下不要当背景用。' % (c['file'], why))
922
- for f in fonts[:2]:
923
- if len(f['stack']) > 1:
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'):
924
1670
  gaps.append('源字体 %s 无 web 授权源,已按 font-fallback 表降级到 %s;字形细节与原稿有差异。'
925
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
+
926
1686
  if leftover:
927
1687
  exceptions.append('源 deck 第 %s 页是单页孤例,没有归纳成 archetype;需要类似构图时按最接近的页型改。'
928
1688
  % '、'.join(map(str, leftover)))
@@ -930,7 +1690,7 @@ def main(argv=None):
930
1690
  emit_manifest(d, assets, ldir)
931
1691
  emit_frontmatter(d, tokens, fonts, roles, anchors, gaps, ldir)
932
1692
  emit_layouts(archetypes, ldir)
933
- emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, ldir)
1693
+ emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, ldir)
934
1694
  emit_brief(d, (tokens, rest, fonts, roles, assets, rejected, todos, archetypes, cands, sheet,
935
1695
  leftover, lsheet), ldir)
936
1696