@lark-apaas/coding-steering 0.1.18-dev.857e860 → 0.1.18-dev.87ec805

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (20) hide show
  1. package/package.json +1 -1
  2. package/steering/design-html/skills/pptx-style-extract/SKILL.md +40 -18
  3. package/steering/design-html/skills/pptx-style-extract/font-fallback.yaml +3 -3
  4. package/steering/design-html/skills/pptx-style-extract/scripts/census.py +18 -12
  5. package/steering/design-html/skills/pptx-style-extract/scripts/check_v2.py +153 -8
  6. package/steering/design-html/skills/pptx-style-extract/scripts/draft.py +991 -185
  7. package/steering/design-html/skills/pptx-style-extract/scripts/extract.py +319 -23
  8. package/steering/design-html/skills/pptx-style-extract/scripts/ooxml.py +1 -1
  9. package/steering/design-html/skills/pptx-style-extract/scripts/package.py +165 -19
  10. package/steering/design-html/skills/pptx-style-extract/scripts/parts.py +6 -3
  11. package/steering/design-html/skills/pptx-style-extract/scripts/query.py +4 -9
  12. package/steering/design-html/skills/pptx-style-extract/scripts/render_pages.py +14 -8
  13. package/steering/design-html/skills/pptx-style-extract/scripts/test_background_composite.py +57 -0
  14. package/steering/design-html/skills/pptx-style-extract/scripts/test_color_contract.py +58 -0
  15. package/steering/design-html/skills/pptx-style-extract/scripts/test_design_consumer_contract.py +59 -0
  16. package/steering/design-html/skills/pptx-style-extract/scripts/test_layout_css.py +98 -0
  17. package/steering/design-html/skills/pptx-style-extract/scripts/test_rounded_contract.py +112 -0
  18. package/steering/design-html/skills/pptx-style-extract/v2-format-spec.md +24 -15
  19. package/steering/design-html/skills/slide-deck/SKILL.md +15 -20
  20. package/steering/design-html/skills/slide-deck/scripts/check_local_references.py +179 -0
@@ -19,12 +19,39 @@ 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
+ OPAQUE_ENOUGH = 128 # 能当背景的最低不透明度:低于半透明就遮不住底下的东西,
28
+ # 那是叠加装饰不是背景
29
+ FILL_MANY = 5 # 「被大量当填充铺开」的次数下限,用于区分卡片底与偶发用色
30
+ BG_CONTENT_CAP = 5 # 内容页背景收几张:再多消费端也挑不过来,超出的写进 TODO 交人取舍
31
+ SHEET_CAP = 12 # 联系表展示上限;进包的资产不受它约束,一张都不截
32
+
22
33
  HERE = os.path.dirname(os.path.abspath(__file__))
23
34
  SKILL_ROOT = os.path.dirname(HERE)
24
35
  SYS_FALLBACK = '"PingFang SC", "Microsoft YaHei", sans-serif'
25
36
 
26
37
 
27
38
  # ---------------------------------------------------------------- 小工具
39
+ # 被名额截掉的东西统一记在这里,最后并进 gaps。截断本身是必要的(色板 40 个 token
40
+ # 消费端挑不过来),但**不说**就成了「悄悄少了东西而产物看起来正常」——消费端会以为
41
+ # 它拿到的就是全部。
42
+ _TRUNCATED = []
43
+
44
+
45
+ def note_truncation(kind, kept, total, advice='', where=''):
46
+ """记一条「这里按名额截断了」。kept >= total 时什么都不记。
47
+
48
+ 按 kind 归并成一条 gap:同一类截断逐处各写一行会淹掉别的 gaps。
49
+ """
50
+ if total > kept:
51
+ _TRUNCATED.append((kind, kept, total, advice, where))
52
+ return kept
53
+
54
+
28
55
  def hex2rgb(h):
29
56
  h = h.lstrip('#')
30
57
  return tuple(int(h[i:i + 2], 16) for i in (0, 2, 4))
@@ -85,8 +112,8 @@ def q(v):
85
112
  def bg_colors(d):
86
113
  """页面/版式/母版的 `background` 声明里出现的底色,按声明次数排序。
87
114
 
88
- 「哪个色是底色」是直读事实(bgPr / bgRef),不用靠亮度猜——猜过一次,把只在
89
- 渐变里出现的浅色当成了主底色。
115
+ 「哪个色是底色」是直读事实(bgPr / bgRef),不用靠亮度猜:渐变里出现的浅色,
116
+ 亮度可能比真底色更像底色。
90
117
  """
91
118
  cnt = Counter()
92
119
  rows = (d.get('slides') or []) + (d.get('layouts') or []) \
@@ -108,11 +135,35 @@ def bg_colors(d):
108
135
  return [h for h, _ in cnt.most_common()]
109
136
 
110
137
 
138
+ def _gap_cut(vals, lo, hi):
139
+ """在排序后的值里找最大间隙,切点取间隙中点。
140
+
141
+ 不用中位数:中位数会正好落在某个样本自己身上,它归哪边就只取决于写 >= 还是 >,
142
+ 纯属任意。真正的分界在两族之间的空档里。夹在 [lo, hi] 内,避免整套同色的模板
143
+ 把界推到极端。
144
+ """
145
+ v = sorted(vals)
146
+ if len(v) < 2:
147
+ return (lo + hi) / 2.0
148
+ _, mid = max((v[i + 1] - v[i], (v[i + 1] + v[i]) / 2.0) for i in range(len(v) - 1))
149
+ return min(max(mid, lo), hi)
150
+
151
+
152
+ def palette_cuts(rows):
153
+ """「有彩 vs 中性」「深 vs 浅」的分界,按本模板自己的色分布切。
154
+
155
+ 固定分界必然错一边:低饱和的莫兰迪配色整套都在低位,高饱和的品牌配色整套都在高位。
156
+ """
157
+ sat_cut = _gap_cut([r['sat'] for r in rows], 0.12, 0.45)
158
+ lums = sorted(r['lum'] for r in rows) or [0.0]
159
+ return sat_cut, lums[len(lums) // 2]
160
+
161
+
111
162
  def draft_colors(d, cusage=None):
112
163
  """色板 token:名字按**实际用法**定,不只看亮度饱和度。
113
164
 
114
165
  只看 lum/sat 会把「主要用来填色的纯黑」命名成 ink(文字色)、把「只出现在渐变里的
115
- 浅蓝」命名成 surface-alt——实测都发生过。这里先看它在形状上主要干什么,再结合
166
+ 浅蓝」命名成 surface-alt。这里先看它在形状上主要干什么,再结合
116
167
  亮度定名;用量太少的直接不进色板。
117
168
  """
118
169
  cusage = cusage or {}
@@ -131,9 +182,11 @@ def draft_colors(d, cusage=None):
131
182
  rows.append({'hex': h, 'n': c['n'], 'lum': lum(rgb), 'sat': satu(rgb),
132
183
  'use': u, 'use_n': tot, 'main': main})
133
184
  # 用量只用来**命名**,不作准入门槛——color_usage 只数形状级的填充/描边/文字,
134
- # 背景 p:bg 与主题色不在其中,拿它筛会把色板砍到只剩一两个(实测塌到 1 个)。
185
+ # 背景 p:bg 与主题色不在其中,拿它筛会把色板砍到只剩极少数几个。
135
186
  strong = sorted(rows, key=lambda r: -r['n'])
136
187
 
188
+ SAT_CUT, LUM_CUT = palette_cuts(rows)
189
+
137
190
  tokens, used = [], set()
138
191
 
139
192
  def take(pred, names):
@@ -153,7 +206,7 @@ def draft_colors(d, cusage=None):
153
206
  return 'unknown' # 形状层看不到用法,退回亮度/饱和度判断
154
207
 
155
208
  # 墨色:主要用来写字(或看不出用法但本身是深中性色),且不是彩色
156
- take(lambda r: r['sat'] < 0.3 and r['lum'] < 0.5
209
+ take(lambda r: r['sat'] < SAT_CUT and r['lum'] < min(LUM_CUT, LUM_MID)
157
210
  and (kind(r) == 'text' or kind(r) == 'unknown'), ['ink', 'ink-muted'])
158
211
  # 底色:直接取页面 background 声明里的色,按声明次数排
159
212
  grounds = bg_colors(d)
@@ -166,14 +219,16 @@ def draft_colors(d, cusage=None):
166
219
  break
167
220
 
168
221
  # 卡片/面板底:页面底色之外,真被大量当填充铺开的浅色(≥5 处才算)
169
- take(lambda r: r['lum'] > 0.85 and r['sat'] < 0.2
170
- and (r['use'].get('填充') or 0) >= 5, ['surface-raised'])
222
+ take(lambda r: r['lum'] > max(LUM_CUT, 0.85) and r['sat'] < SAT_CUT
223
+ and (r['use'].get('填充') or 0) >= FILL_MANY, ['surface-raised'])
171
224
  # 表达色:有彩度的按频次排
172
- take(lambda r: r['sat'] >= 0.3, ['primary', 'accent', 'accent-2', 'accent-3'])
225
+ take(lambda r: r['sat'] >= SAT_CUT, ['primary', 'accent', 'accent-2', 'accent-3'])
173
226
  # 其余低饱和色一律 neutral-N——它到底是卡片底、分隔线还是描边,数据分不出来,
174
227
  # 就不要用名字去替消费方下结论;真实用法写在 Colors 表的用途列里。
175
- take(lambda r: r['sat'] < 0.3, ['neutral', 'neutral-2', 'neutral-3'])
176
- rest = [r for r in rows if r['hex'] not in used][:6]
228
+ take(lambda r: r['sat'] < SAT_CUT, ['neutral', 'neutral-2', 'neutral-3'])
229
+ spare = [r for r in rows if r['hex'] not in used]
230
+ note_truncation('设计色', 6, len(spare), '色板只收主要色,其余在联系表里看')
231
+ rest = spare[:6]
177
232
  return tokens, rest, rows
178
233
 
179
234
 
@@ -205,15 +260,19 @@ def norm(s):
205
260
  return re.sub(r'[\s\-_]', '', s or '').lower()
206
261
 
207
262
 
263
+ OFFICE_DEFAULT_FONTS_NORM = {norm(x) for x in OFFICE_DEFAULT_FONTS}
264
+
265
+
208
266
  def cover_slot_colors(tokens, archetypes, rows, cusage):
209
- """slot 里出现的每个色值都必须在色板里有名字。
267
+ """slot CSS 里出现的每个色值都必须在色板里有名字。
210
268
 
211
- Hard Rules 写「颜色只用 colors 里的 token」,而 slot 的 color 是从模板直读的,
212
- 两者不对齐就等于产物自己违反自己的规则(实测 vo-lite 的 slot 用了 #27C5F3 /
213
- #1664FF 两个色板外的色)。这里把缺的补进色板,按用法归族命名。
269
+ Hard Rules 写「颜色只用 colors 里的 token」,而 slot CSS 的 color 是从模板直读的,
270
+ 两者不对齐就等于产物自己违反自己的规则——slot 的色值直读自模板,未必都已进
271
+ 色板。这里把缺的补进色板,按用法归族命名。
214
272
  """
215
273
  have = {r['hex'].upper() for _, r in tokens}
216
274
  by_hex = {r['hex'].upper(): r for r in rows}
275
+ sat_cut, lum_cut = palette_cuts(rows) # 与 draft_colors 同一套切点,别各切各的
217
276
  used = [n for n, _ in tokens]
218
277
 
219
278
  def nxt(fam):
@@ -227,15 +286,15 @@ def cover_slot_colors(tokens, archetypes, rows, cusage):
227
286
  added = []
228
287
  for a in archetypes:
229
288
  for s in a['slots']:
230
- h = (s.get('color') or '').upper()
289
+ h = (s.get('_color') or '').upper()
231
290
  if not h.startswith('#') or h in have:
232
291
  continue
233
292
  have.add(h)
234
293
  r = by_hex.get(h)
235
294
  if r is None: # 普查里没有这个色(理论上不该发生),跳过不编造
236
295
  continue
237
- fam = ('ink' if r['sat'] < 0.3 and r['lum'] < 0.5
238
- else 'accent' if r['sat'] >= 0.3 else 'neutral')
296
+ fam = ('ink' if r['sat'] < sat_cut and r['lum'] < min(lum_cut, LUM_MID)
297
+ else 'accent' if r['sat'] >= sat_cut else 'neutral')
239
298
  name = nxt(fam)
240
299
  used.append(name)
241
300
  tokens.append((name, r))
@@ -268,6 +327,7 @@ def draft_fonts(d):
268
327
  return None
269
328
 
270
329
  out = []
330
+ note_truncation('字族', 4, len(ranked), '只报渲染量最大的几族')
271
331
  for key, g in ranked[:4]:
272
332
  fam = resolve(sorted(g['names'], key=len))
273
333
  stack = [sorted(g['names'], key=len)[0]]
@@ -317,8 +377,8 @@ def quant(hit, total):
317
377
  def color_usage(shapes, d=None):
318
378
  """每个色值在形状上的真实用法计数:填充 / 渐变 / 描边 / 文字。
319
379
 
320
- 用途列以前是写死的字典(把 #7861FF 说成「渐变收尾色」,实测它是填充 14 / 描边 9 /
321
- 渐变 1)。这里改为从 shapes 直接数,数不到就如实说数不到。
380
+ 用途列不能靠预设字典猜——同一个色在不同模板里的主用途完全不同。这里从 shapes
381
+ 直接数,数不到就如实说数不到。
322
382
  """
323
383
  def hx(c):
324
384
  return (c.get('hex') or '').upper() if isinstance(c, dict) else ''
@@ -375,7 +435,13 @@ def usage_phrase(counter):
375
435
 
376
436
 
377
437
  def draft_anchors(d, tokens, fonts, roles, assets, archetypes):
378
- """anchors 逐条由实测覆盖率产出;证据不足就不生成这一条,不用形容词补。"""
438
+ """anchors 只报测到的数,不下「这套风格是什么」的结论。
439
+
440
+ 这一段在 design.md 里读起来像「设计总纲」,消费端会照它建全局样式。脚本写进去的
441
+ 每一句解读都会被当成规则执行——实测把 1/8 覆盖率的 logo 描述成「跨页不动」,
442
+ 消费端就建了全局 CSS 类,12 页全铺了 logo。所以这里只给覆盖率和计数,
443
+ 「这是不是这套风格的特征」由看得到图的人判断。
444
+ """
379
445
  A = []
380
446
  n_arch = len(archetypes) or 1
381
447
 
@@ -384,7 +450,8 @@ def draft_anchors(d, tokens, fonts, roles, assets, archetypes):
384
450
  chroma = [n for n in names if n.startswith(('primary', 'accent'))]
385
451
  if chroma:
386
452
  A.append((chroma[0] + '-led-palette', 'token',
387
- '表达色集中在 %s;其余 token 为底色与文字色' % '、'.join(chroma[:3])))
453
+ '有彩色 token %d 个,用量最大的是 %s'
454
+ % (len(chroma), '、'.join(chroma[:3]))))
388
455
 
389
456
  # 2. 圆角:按普查占比
390
457
  radii = d.get('radii_census') or []
@@ -394,16 +461,15 @@ def draft_anchors(d, tokens, fonts, roles, assets, archetypes):
394
461
  q0 = quant(zero['n'], tot_r)
395
462
  if q0:
396
463
  A.append(('zero-radius', 'token',
397
- '卡片、按钮、面板%s直角,圆角量为零的形状占 %d%%'
398
- % (q0, round(100.0 * zero['n'] / tot_r))))
464
+ '圆角量为零的形状占 %d%%(普查 %d 个带圆角声明的形状)'
465
+ % (round(100.0 * zero['n'] / tot_r), tot_r)))
399
466
 
400
467
  # 3. 满屏底图:按有背景的页型占比
401
468
  with_bg = sum(1 for a in archetypes if a.get('bg'))
402
469
  qb = quant(with_bg, n_arch)
403
470
  if qb:
404
471
  A.append(('full-bleed-ground', 'pattern',
405
- '页型%s由整幅铺满的底图打底(%d/%d),元素浮在图上而不是浮在纯色块上'
406
- % (qb, with_bg, n_arch)))
472
+ '%d/%d 个页型声明了整幅铺满的底图' % (with_bg, n_arch)))
407
473
 
408
474
  # 4. 标识:位置是不是真的固定,看有几个不同的 box
409
475
  logo_slots = [s for a in archetypes for s in a['slots']
@@ -412,9 +478,14 @@ def draft_anchors(d, tokens, fonts, roles, assets, archetypes):
412
478
  if any(str(s.get('asset') or '').startswith(('logo', 'slogan'))
413
479
  for s in a['slots']))
414
480
  boxes = {tuple(s['box']) for s in logo_slots}
415
- if logo_arch and len(boxes) == 1:
481
+ # anchors 是「这套风格的定义性特征」,消费端读它来建全局样式。只在少数页型出现的
482
+ # 东西写进来,等于宣布它是全局元素——实测某模板 logo 只在 1/8 个页型上,anchor 仍
483
+ # 写成「跨页不动」,消费端据此建了个全局 CSS 类,12 页全铺了 logo。
484
+ # 所以这里和其他 anchor 用同一把尺:覆盖率不过半就不进 anchors。
485
+ ql = quant(logo_arch, n_arch)
486
+ if logo_arch and len(boxes) == 1 and ql:
416
487
  A.append(('corner-locked-logo', 'component',
417
- '品牌标识在 %d/%d 个页型上出现,位置尺寸完全一致,跨页不动'
488
+ '品牌标识出现在 %d/%d 个页型上,这些页型里它的 box 完全一致'
418
489
  % (logo_arch, n_arch)))
419
490
  elif len(boxes) > 1:
420
491
  A.append(('logo-moves-by-archetype', 'component',
@@ -424,58 +495,59 @@ def draft_anchors(d, tokens, fonts, roles, assets, archetypes):
424
495
  # 5. 渐变:按普查计数
425
496
  if (d.get('geom_census') or {}).get('gradient_fills'):
426
497
  A.append(('gradient-accent', 'pattern',
427
- '强调元素用线性渐变承载,全档共 %d 处渐变填充'
428
- % (d['geom_census']['gradient_fills'])))
498
+ '全档共 %d 处渐变填充' % (d['geom_census']['gradient_fills'])))
429
499
 
430
500
  # 6. 层级:字号跨度 + 字重是否单一(字重真单一才敢说「不靠字重」)
431
501
  disp, body = roles.get('display'), roles.get('body')
432
- if disp and body and disp['sz_px'] >= body['sz_px'] * 3:
433
- ws = {s.get('weight') for a in archetypes for s in a['slots'] if s.get('weight')}
502
+ if disp and body and disp['sz_px'] > body['sz_px']:
503
+ ws = {s.get('_font_weight') for a in archetypes for s in a['slots']
504
+ if s.get('_font_weight')}
434
505
  tail = (',字重只用 %s 一档' % list(ws)[0]) if len(ws) == 1 else ''
435
506
  A.append(('size-driven-hierarchy', 'pattern',
436
- '层级靠字号跨度拉开,展示档与正文档差 %.1f 倍,见 typography%s'
507
+ '最大字号档与正文档相差 %.1f 倍(见 typography)%s'
437
508
  % (disp['sz_px'] / body['sz_px'], tail)))
438
509
 
439
510
  # 7. 阴影:只在描边极少时才敢说「不用描边分隔」
440
511
  eff = d.get('effects_census') or {}
441
512
  if eff.get('outerShdw'):
442
513
  A.append(('soft-shadow-card', 'component',
443
- '容器用外阴影托起,全档 %d 处 outerShdw' % eff['outerShdw']))
514
+ '全档 %d 处 outerShdw 外阴影' % eff['outerShdw']))
444
515
 
445
516
  # 8. 双字族:只陈述分工存在,不断言「同一行混排」(普查没采集混排)
446
517
  tot_r_font = sum(f['rendered'] for f in fonts) or 1
447
- if len(fonts) >= 2 and fonts[1]['rendered'] >= tot_r_font * 0.15:
518
+ if len(fonts) >= 2 and fonts[1]['rendered']:
448
519
  A.append(('dual-family-typesetting', 'token',
449
- '正文与展示分属两套字族:%s %s,各自渲染 %d / %d 处'
450
- % (fonts[0]['names'][0], fonts[1]['names'][0],
451
- fonts[0]['rendered'], fonts[1]['rendered'])))
520
+ '用了两套字族:%s 渲染 %d 处、%s 渲染 %d 处'
521
+ % (fonts[0]['names'][0], fonts[0]['rendered'],
522
+ fonts[1]['names'][0], fonts[1]['rendered'])))
452
523
 
453
524
  # 9. 安全区:只在各页型正文左边界真的收敛时才写
525
+ # 「多宽算正文槽」按本包自己的槽宽分布定:固定 px 门槛在窄版心模板上会一个都不剩
526
+ widths = sorted(s['box'][2] for a in archetypes for s in a['slots'] if not s.get('asset'))
527
+ w_cut = widths[len(widths) // 2] if widths else 0
454
528
  lefts = [s['box'][0] for a in archetypes for s in a['slots']
455
- if not s.get('asset') and s['box'][2] > 200]
529
+ if not s.get('asset') and s['box'][2] >= w_cut]
456
530
  if len(lefts) >= 4:
457
531
  common = Counter(lefts).most_common(1)[0]
458
532
  qs = quant(common[1], len(lefts))
459
533
  if qs:
460
534
  A.append(('shared-left-margin', 'token',
461
- '正文%s对齐同一条左边界(%d/%d 个正文槽共用,坐标见 layouts)'
462
- % (qs, common[1], len(lefts))))
535
+ '%d/%d 个正文槽的左边界落在同一个 x 上(坐标见 layouts)'
536
+ % (common[1], len(lefts))))
463
537
 
464
538
  # 10. 双主题:直读事实
465
539
  themes = (d.get('theme_topology') or {}).get('themes') or []
466
540
  if len(themes) > 1:
467
541
  A.append(('dual-theme-masters', 'token',
468
- '模板带 %s 两套主题母版,同一页型有深浅两版,配色随主题整体反转'
469
- % ' / '.join(themes)))
542
+ '模板声明了 %s 两套主题母版' % ' / '.join(themes)))
470
543
 
471
544
  # 11. 画布:直读事实(兜底凑数也只用真事实)
472
545
  cv = d['canvas']['px']
473
546
  A.append(('fixed-canvas', 'token',
474
- '画布固定 %d×%d,所有坐标是这张画布上的绝对像素,不做响应式重排'
475
- % (cv[0], cv[1])))
547
+ '画布 %d×%d,layouts 里的坐标都是这张画布上的绝对像素' % (cv[0], cv[1])))
476
548
  if len(archetypes) >= 3:
477
549
  A.append(('archetype-catalog', 'pattern',
478
- '模板给出 %d 种页型,搭页从中挑,不要自创版式' % len(archetypes)))
550
+ '归纳出 %d 种页型' % len(archetypes)))
479
551
 
480
552
  seen, out = set(), []
481
553
  for a in A:
@@ -496,8 +568,9 @@ def draft_scale(d, archetypes=()):
496
568
  display = by_px.get(max(title_sz)) if title_sz else None
497
569
  big = [t for t in ts if t['n'] >= 2] or ts
498
570
  display = display or big[0]
499
- body_pool = [t for t in ts if t['sz_px'] <= 48]
500
- body = max(body_pool, key=lambda t: t['n']) if body_pool else ts[-1]
571
+ # 正文档 = 渲染次数最多的那一档。不设「多大算正文」的上限:大字号排版的模板
572
+ # 正文本来就可能比别的模板的标题还大,预设上限会把它整档判错。
573
+ body = max([t for t in ts if t is not display] or ts, key=lambda t: t['n'])
501
574
  heading_pool = [t for t in ts if body['sz_px'] * 1.3 <= t['sz_px'] < display['sz_px']]
502
575
  heading = max(heading_pool, key=lambda t: t['n']) if heading_pool else None
503
576
  small_pool = [t for t in ts if t['sz_px'] < body['sz_px']]
@@ -541,6 +614,53 @@ def probe_image(path):
541
614
  return info
542
615
 
543
616
 
617
+ def bg_busy_map(path, canvas, cells=12):
618
+ """把背景图切成网格,报每格的**局部对比度**(该格内亮度极差)。
619
+
620
+ 「哪里不能压文字」的本质是「哪里花」。整幅渐变的底图各格对比度都低,说明没有
621
+ 视觉主体;有山峰、人物、产品图的底图会在主体处出现明显更高的对比度。这里只出
622
+ 客观数值和一个据此推出的草案,最终由看得到图的人定。
623
+ """
624
+ try:
625
+ from PIL import Image
626
+ except Exception:
627
+ return None
628
+ try:
629
+ im = Image.open(path).convert('L').resize((cells * 8, cells * 8))
630
+ except Exception:
631
+ return None
632
+ px = im.load()
633
+ grid = []
634
+ for gy in range(cells):
635
+ row = []
636
+ for gx in range(cells):
637
+ vals = [px[gx * 8 + x, gy * 8 + y] for y in range(8) for x in range(8)]
638
+ row.append(max(vals) - min(vals))
639
+ grid.append(row)
640
+ flat = sorted(v for row in grid for v in row)
641
+ if not flat:
642
+ return None
643
+ med = flat[len(flat) // 2]
644
+ hi = flat[int(len(flat) * 0.9)]
645
+ # 主体 = 对比度显著高于全图中位数的连片格子。阈值取「中位数与九分位的中点」,
646
+ # 由本图自己的分布定,不用固定值。
647
+ cut = (med + hi) / 2.0
648
+ cW, cH = canvas
649
+ hot = [(gx, gy) for gy in range(cells) for gx in range(cells) if grid[gy][gx] > cut]
650
+ if not hot:
651
+ return {'busy': None, 'median': med, 'p90': hi, 'why': '各处对比度一致,没有更花的区域'}
652
+ xs = [g[0] for g in hot]
653
+ ys = [g[1] for g in hot]
654
+ span = ((max(xs) - min(xs) + 1) * (max(ys) - min(ys) + 1)) / float(cells * cells)
655
+ if span > 0.5:
656
+ # 热格散落全图,外接矩形几乎覆盖整幅——圈出来等于没圈
657
+ return {'busy': None, 'median': med, 'p90': hi, 'why': '较花的格子散布全图,圈不出单一主体'}
658
+ box = [round(min(xs) * cW / cells), round(min(ys) * cH / cells),
659
+ round((max(xs) - min(xs) + 1) * cW / cells),
660
+ round((max(ys) - min(ys) + 1) * cH / cells)]
661
+ return {'busy': box, 'median': med, 'p90': hi, 'span': round(span, 2)}
662
+
663
+
544
664
  def copy_logo_candidates(outdir, logo_pool):
545
665
  if not logo_pool:
546
666
  return []
@@ -618,14 +738,24 @@ def draft_assets(d, outdir, bg_needed=(), cover_media=None, bg_under=None):
618
738
  bg_needed = {alias.get(m, m) for m in (bg_needed or ())}
619
739
 
620
740
  assets, rejected, todos = [], [], []
741
+ over_cap_bgs = []
621
742
  logo_pool = []
622
743
  bg_under = bg_under or {}
623
744
  bg_i = 0
624
- canvas_w = d['canvas']['px'][0]
745
+ canvas_w, canvas_h = d['canvas']['px']
625
746
  for c in kept:
626
747
  if c['probe'].get('near_blank'):
627
748
  rejected.append((c, '近全透明(alpha 均值 %.0f/255),PPT 里看不见' % c['probe']['alpha_mean']))
628
749
  continue
750
+ # 铺满 ≠ 能当背景。背景的定义性属性是**遮盖**:它得挡住底下的东西。一张大半透明
751
+ # 的图铺满整页也遮不住任何像素,它在 PPT 里是叠在幻灯片底色上的一层装饰(顶部
752
+ # 光晕之类),底色才是真背景。实测某模板一张 alpha 均值 30/255、72% 完全透明的
753
+ # 顶部光晕被当成满屏背景收进包,消费端每页铺它,顶部就多出一条原稿没有的浓色带。
754
+ am = c['probe'].get('alpha_mean')
755
+ if c['fullscreen'] and am is not None and am < OPAQUE_ENOUGH:
756
+ rejected.append((c, 'alpha 均值只有 %.0f/255,遮不住底下的东西——'
757
+ '它是叠在底色上的装饰层,不是背景' % am))
758
+ continue
629
759
  if c['fullscreen']:
630
760
  if c['media'] == cover_media:
631
761
  assets.append({'id': 'bg-cover', 'kind': 'background', 'role': 'cover',
@@ -633,21 +763,23 @@ def draft_assets(d, outdir, bg_needed=(), cover_media=None, bg_under=None):
633
763
  # 只有真出了压缩版才能带原图;否则 path/full 指向同一
634
764
  # 文件,package.py 必 FAIL(封面不需要转码时就会踩到)
635
765
  'use_full': c['has_compressed']})
636
- elif c['media'] in bg_needed and bg_i < 5:
766
+ elif c['media'] in bg_needed and bg_i < BG_CONTENT_CAP:
637
767
  bg_i += 1
638
768
  assets.append({'id': 'bg-content-%d' % bg_i, 'kind': 'background',
639
769
  'role': 'content', 'src': c, 'use_full': False})
770
+ elif c['media'] in bg_needed:
771
+ over_cap_bgs.append(c)
772
+ rejected.append((c, '有页型以它为主底,但内容页背景已收满 %d 张' % BG_CONTENT_CAP))
640
773
  else:
641
774
  rejected.append((c, '满屏图但没有页面以它为主底(只在版式层备用)'))
642
- elif c['w_pct'] < 30 and c['n'] >= 2:
775
+ elif c['w_pct'] < SMALL_IMG_W_PCT and c['n'] >= REPEAT_MIN:
776
+ # 品牌标识的共性是「小、重复出现、贴角」。这里只按贴角程度排序给出首选,
777
+ # 不设及格线——「多少分算 logo」没有客观依据,判断交 L 层,分项证据随 TODO 给出。
643
778
  b = c['box']
644
- score = 0
645
- score += 3 if b.get('y', 999) < 160 else (1 if b.get('y', 0) > canvas_w * 0.5 else 0)
646
- score += 2 if b.get('x', 999) < 200 or b.get('x', 0) > canvas_w * 0.7 else 0
647
- ar = (b.get('w') or 1) / max(b.get('h') or 1, 1)
648
- score += 1 if 1.0 <= ar <= 8.0 else 0
649
- score += 1 if c['n'] >= 2 else 0
650
- logo_pool.append((score, c))
779
+ edge_x = min(b.get('x', 0), max(canvas_w - (b.get('x', 0) + (b.get('w') or 0)), 0))
780
+ edge_y = min(b.get('y', 0), max(canvas_h - (b.get('y', 0) + (b.get('h') or 0)), 0))
781
+ corner = (edge_x / canvas_w) + (edge_y / canvas_h) # 越小越贴角
782
+ logo_pool.append((corner, c))
651
783
  else:
652
784
  rejected.append((c, '内容区图片(占宽 %.0f%%,出现 %d 次)' % (c['w_pct'], c['n'])))
653
785
 
@@ -661,34 +793,45 @@ def draft_assets(d, outdir, bg_needed=(), cover_media=None, bg_under=None):
661
793
  from PIL import Image
662
794
  im = Image.open(os.path.join(outdir, row['out'])).convert('RGB')
663
795
  b = c['box']
664
- sx, sy = im.width / float(canvas_w), im.height / float(d['canvas']['px'][1])
796
+ sx, sy = im.width / float(canvas_w), im.height / float(canvas_h)
665
797
  crop = im.crop((int(b.get('x', 0) * sx), int(b.get('y', 0) * sy),
666
798
  max(int((b.get('x', 0) + b.get('w', 1)) * sx), 1),
667
799
  max(int((b.get('y', 0) + b.get('h', 1)) * sy), 1))).resize((16, 16))
668
800
  raw = crop.tobytes()
669
801
  px = [raw[i:i + 3] for i in range(0, len(raw), 3)]
670
- return 'light' if sum(lum(p) for p in px) / len(px) > 0.55 else 'dark'
802
+ return 'light' if sum(lum(p) for p in px) / len(px) > LUM_MID else 'dark'
671
803
  except Exception:
672
804
  return None
673
805
 
674
- logo_pool.sort(key=lambda kv: (-kv[0], -kv[1]['n']))
675
- for i, (score, c) in enumerate(logo_pool):
806
+ # 贴角是品牌标识的定义性特征:离两边都超过画布 1/4 的重复小图,更可能是页内装饰。
807
+ # 这不是「多少分算 logo」那种凑出来的分数线——它直接来自「贴角」这个判据本身。
808
+ LOGO_CORNER_MAX = 0.5 # edge_x/W + edge_y/H,两边各 25% 即到上限
809
+ logo_pool.sort(key=lambda kv: (kv[0], -kv[1]['n']))
810
+ if logo_pool and logo_pool[0][0] > LOGO_CORNER_MAX:
811
+ todos.append('没有贴角的重复小图(最接近的一张离画布边 %.0f%%),本模板可能没有 logo;'
812
+ '确认后要么从联系表挑一张补进 manifest,要么在 gaps 写明模板无品牌标识'
813
+ % (logo_pool[0][0] * 50))
814
+ logo_pool = []
815
+ for i, (corner, c) in enumerate(logo_pool):
676
816
  b = c['box']
677
- if i == 0 and score >= 5:
817
+ if i == 0:
678
818
  assets.append({'id': 'logo-primary', 'kind': 'logo', 'role': None, 'src': c,
679
819
  'use_full': False, 'on_bg': on_bg_of(c)})
680
- todos.append('看联系表确认 `%s`(%.0fx%.0f @ %.0f,%.0f,出现 %d 次)真是品牌 logo;'
820
+ todos.append('看联系表确认 `%s` 真是品牌 logo(%.0fx%.0f @ %.0f,%.0f,出现 %d 次,'
821
+ '离画布边 %.0f%%,是所有小图里最贴角的一张);'
681
822
  '不是就把 manifest 的 logo-primary 换成别的候选或整条删掉'
682
- % (c['file'], b.get('w', 0), b.get('h', 0), b.get('x', 0), b.get('y', 0), c['n']))
823
+ % (c['file'], b.get('w', 0), b.get('h', 0), b.get('x', 0), b.get('y', 0),
824
+ c['n'], corner * 50))
683
825
  else:
684
- rejected.append((c, '重复小图(%.0fx%.0f @ %.0f,%.0f),logo 相似度低于首选'
685
- % (b.get('w', 0), b.get('h', 0), b.get('x', 0), b.get('y', 0))))
826
+ rejected.append((c, '重复小图(%.0fx%.0f @ %.0f,%.0f),贴角程度 %.0f%% 不如首选'
827
+ % (b.get('w', 0), b.get('h', 0), b.get('x', 0), b.get('y', 0),
828
+ corner * 50)))
686
829
 
687
830
  # 体量预算:包内资产总量超 20MB 直接 FAIL(V2-6)。`use_full` 的原图是唯一可能
688
- # 单张爆预算的东西(实测某模板封面 tif 单张 23.7MB),所以在草案期就先丢 full,
831
+ # 单张爆预算的东西(未压缩的封面级大图可以单张达到数十 MB),所以在草案期就先丢 full,
689
832
  # 不要留给 L 层去撞门禁再回修。
690
833
  PACK_BUDGET = 20 * 1024 * 1024
691
- est = sum(min(a['src'].get('bytes') or 0, 600 * 1024) for a in assets)
834
+ est = sum(min(a['src'].get('bytes') or 0, ASSET_WARN_SINGLE) for a in assets)
692
835
  for a in sorted([x for x in assets if x['use_full']],
693
836
  key=lambda x: -(x['src'].get('bytes') or 0)):
694
837
  orig = a['src'].get('bytes') or 0
@@ -699,21 +842,31 @@ def draft_assets(d, outdir, bg_needed=(), cover_media=None, bg_under=None):
699
842
  else:
700
843
  est += orig
701
844
 
845
+ if over_cap_bgs:
846
+ todos.append('模板有 %d 张内容页背景超出 %d 张上限(%s);用到它们的页型在 layouts.md 里'
847
+ '不会有 background,需要就手工补进 manifest 并删掉不重要的那几张'
848
+ % (len(over_cap_bgs), BG_CONTENT_CAP,
849
+ '、'.join(c['file'] for c in over_cap_bgs[:5])))
702
850
  if not any(a['role'] == 'cover' for a in assets):
703
851
  todos.append('没定出封面底图——从联系表挑一张补进 manifest(role: cover),或在 gaps 写明模板无封面主视觉')
704
852
  copy_logo_candidates(outdir, logo_pool)
705
- return assets, rejected, todos, alias
853
+ return assets, rejected, todos, alias, {c['media']: c for c in kept}
706
854
 
707
855
 
708
856
  # ---------------------------------------------------------------- 版式聚类
709
857
  DECOR_MIN = 40.0
710
858
 
711
- # 版式名 → role(模板自己按页型命名时直接用它,别再猜)
859
+ # 版式名 → role(模板自己按页型命名时直接用它,别再猜)。
860
+ # 英文词按整词匹配:裸子串会让短词吃掉长词——`end` 一度把 `agenda`、`Appendix`、
861
+ # `Trends Section` 全判成 closing,表里 `agenda -> section` 那条永远轮不到。
712
862
  ROLE_BY_WORD = [('封面', 'cover'), ('cover', 'cover'), ('首页', 'cover'),
863
+ ('title slide', 'cover'), ('标题幻灯片', 'cover'),
713
864
  ('封底', 'closing'), ('尾页', 'closing'), ('结束', 'closing'),
714
865
  ('致谢', 'closing'), ('谢谢', 'closing'), ('end', 'closing'),
866
+ ('thank you', 'closing'), ('closing', 'closing'),
715
867
  ('章节', 'section'), ('目录', 'section'), ('过渡', 'section'),
716
868
  ('section', 'section'), ('agenda', 'section'),
869
+ ('section header', 'section'), ('节标题', 'section'),
717
870
  ('金句', 'quote'), ('问句', 'quote'), ('引言', 'quote'), ('quote', 'quote'),
718
871
  ('空白', 'blank'), ('blank', 'blank')]
719
872
  PH_TO_TYPE = {'title': 'title', 'ctrTitle': 'title', 'subTitle': 'subtitle',
@@ -723,11 +876,19 @@ PH_TO_TYPE = {'title': 'title', 'ctrTitle': 'title', 'subTitle': 'subtitle',
723
876
 
724
877
 
725
878
  def role_of_name(name):
879
+ """版式名 → role。认不出返回 None,由调用方降置信度并留 TODO——不要静默当 content。
880
+
881
+ 词表只覆盖中英文;换一种语言命名的模板会整份认不出。那时全落 content 且机检照过,
882
+ 消费端拿到的是「每一页都是内容页」,封面/章节/结束页的语义整个丢掉且无处可查。
883
+ """
726
884
  low = (name or '').lower()
727
885
  for word, role in ROLE_BY_WORD:
728
- if word in low:
886
+ if word.isascii():
887
+ if re.search(r'(?<![a-z])%s(?![a-z])' % re.escape(word), low):
888
+ return role
889
+ elif word in low:
729
890
  return role
730
- return 'content'
891
+ return None
731
892
 
732
893
 
733
894
  def clean_layout_name(name):
@@ -735,17 +896,63 @@ def clean_layout_name(name):
735
896
  return re.sub(r'^\d+[_\-\s]*', '', (name or '').strip()) or '未命名版式'
736
897
 
737
898
 
899
+ def is_bleed(s):
900
+ return (s.get('kind') == 'pic' and (s.get('w_pct') or 0) >= 95
901
+ and (s.get('h_pct') or 0) >= 95)
902
+
903
+
904
+ def top_bleed_media(shapes):
905
+ """一串形状里最上层的满屏图。
906
+
907
+ OOXML 的 spTree 是绘制序,靠后的画在上面。一个版式常叠两张满屏图——通用底纹在
908
+ 下、这一页的主视觉在上——所以看得见的是最后那张。取第一张会拿到底纹,实测让
909
+ 章节页的深蓝主视觉被换成了另一张鲜蓝底纹,成品与原稿完全不是一个颜色。
910
+ """
911
+ out = None
912
+ for s in shapes:
913
+ if is_bleed(s) and s.get('media'):
914
+ out = s['media']
915
+ return out
916
+
917
+
918
+ def slot_overlaps(slots):
919
+ """同一页型里坐标互相重叠的槽对。只报事实,不改坐标——坐标是从模板量的。"""
920
+ out = []
921
+ for i in range(len(slots)):
922
+ for j in range(i + 1, len(slots)):
923
+ a, b = slots[i].get('box'), slots[j].get('box')
924
+ if not (a and b):
925
+ continue
926
+ ox = min(a[0] + a[2], b[0] + b[2]) - max(a[0], b[0])
927
+ oy = min(a[1] + a[3], b[1] + b[3]) - max(a[1], b[1])
928
+ if ox > 0 and oy > 0:
929
+ out.append('%s×%s 叠 %dx%d' % (slots[i].get('role'), slots[j].get('role'),
930
+ round(ox), round(oy)))
931
+ return out
932
+
933
+
934
+ def css_number(value, digits=3):
935
+ """CSS 数值稳定格式:整数不带小数,其余去掉无意义尾零。"""
936
+ number = round(float(value), digits)
937
+ if number == int(number):
938
+ return str(int(number))
939
+ return ('%.*f' % (digits, number)).rstrip('0').rstrip('.')
940
+
941
+
738
942
  def slot_style(s):
739
- """占位符自带的排版样式——字号/色值/对齐/字重都是直读,不给消费端留编的空间。
943
+ """占位符自带的排版样式,统一转成可直接写进 HTML style 的 CSS 声明串。
740
944
 
741
945
  样式可能在三层:lstStyle.lvl1pPr(版式占位符常用)、段落 defRPr(Mac Office
742
946
  导出把大量属性写在这一层)、段落 pPr(对齐)。逐层兜底,缺一层就往下取。
947
+
948
+ `box` 是布局几何,继续由 slot 独立承载;其余渲染属性不再泄漏成 size / color /
949
+ align / insets_px 等 PPTX 中间字段。下划线开头的键仅供 draft 内部统计,emit_layouts
950
+ 不会写进消费者产物。
743
951
  """
744
952
  txt = s.get('text') or {}
745
953
  ls = dict((txt.get('lstStyle') or {}).get('lvl1pPr') or {})
746
954
  # 四层逐级兜底,按 OOXML 的就近原则:run rPr → 段落 defRPr → 段落 pPr → lstStyle。
747
- # 只枚举前几层会整份漏掉——实测两个 PptxGenJS 导出的样本把字号全写在 run rPr 上,
748
- # 123/123、105/105 个文本形状都有 sz_px,而 lvl1pPr 一个都没有。
955
+ # 只枚举前几层会整份漏掉——有的导出器把字号全写在 run rPr 上,lstStyle 一个都没有。
749
956
  for para in (txt.get('paragraphs') or []):
750
957
  srcs = [r.get('rPr') or {} for r in (para.get('runs') or [])]
751
958
  srcs.append(para.get('defRPr') or {})
@@ -761,44 +968,130 @@ def slot_style(s):
761
968
  anysz = shape_sz(s)
762
969
  if anysz:
763
970
  ls['sz_px'] = anysz
971
+ body = txt.get('bodyPr') or {}
972
+ css = []
764
973
  out = {}
974
+ insets = body.get('insets_px') or {}
975
+ if insets:
976
+ css.append('box-sizing: border-box')
977
+ css.append('padding: %spx %spx %spx %spx' % (
978
+ css_number(insets.get('tIns', 0) or 0),
979
+ css_number(insets.get('rIns', 0) or 0),
980
+ css_number(insets.get('bIns', 0) or 0),
981
+ css_number(insets.get('lIns', 0) or 0),
982
+ ))
765
983
  if ls.get('sz_px'):
766
- out['size'] = round(ls['sz_px'])
984
+ size = round(ls['sz_px'])
985
+ css.append('font-size: %dpx' % size)
986
+ out['_font_size'] = size
987
+ weight = ls.get('weight') or (700 if ls.get('bold') else None)
988
+ if weight:
989
+ css.append('font-weight: %s' % weight)
990
+ out['_font_weight'] = weight
991
+ if ls.get('italic'):
992
+ css.append('font-style: italic')
993
+ decorations = []
994
+ if ls.get('underline'):
995
+ decorations.append('underline')
996
+ if ls.get('strike'):
997
+ decorations.append('line-through')
998
+ if decorations:
999
+ css.append('text-decoration: %s' % ' '.join(decorations))
1000
+ if ls.get('spc_px') is not None:
1001
+ css.append('letter-spacing: %spx' % css_number(ls['spc_px']))
767
1002
  col = (ls.get('color') or {}).get('resolved')
768
1003
  if col:
769
- out['color'] = col
770
- if ls.get('weight'):
771
- out['weight'] = ls['weight']
772
- elif ls.get('bold'):
773
- out['weight'] = 700
774
- if ls.get('algn') and ls['algn'] not in ('l', 'just'):
775
- out['align'] = {'ctr': 'center', 'r': 'right'}.get(ls['algn'], ls['algn'])
776
- anchor = ((s.get('text') or {}).get('bodyPr') or {}).get('anchor')
1004
+ css.append('color: %s' % col)
1005
+ out['_color'] = col
1006
+ else:
1007
+ # 占位符的字色也可以是 gradFill(章节页的大号序号常这么做)。解析层已经把
1008
+ # stops 和角度记全了,这里只取单色就会整条丢掉,消费端只能自己编一个平色。
1009
+ # decor 同一约定:css 是可直接写进 style 的声明串。
1010
+ f = ls.get('fill') or {}
1011
+ if f.get('type') == 'gradient':
1012
+ g = _load_query()._css_gradient(f)
1013
+ if g:
1014
+ css += ['background-image: %s' % g, '-webkit-background-clip: text',
1015
+ 'background-clip: text', 'color: transparent']
1016
+ align = ls.get('algn')
1017
+ if align:
1018
+ css.append('text-align: %s' % {
1019
+ 'l': 'left', 'ctr': 'center', 'r': 'right', 'just': 'justify',
1020
+ }.get(align, align))
1021
+ line_spacing = ls.get('lnSpc') or {}
1022
+ if line_spacing.get('mult'):
1023
+ css.append('line-height: %s' % css_number(line_spacing['mult'] * 1.2))
1024
+ elif line_spacing.get('px'):
1025
+ css.append('line-height: %spx' % css_number(line_spacing['px']))
1026
+ anchor = body.get('anchor')
777
1027
  if anchor in ('ctr', 'b'):
778
- out['valign'] = {'ctr': 'middle', 'b': 'bottom'}[anchor]
1028
+ css += ['display: flex', 'flex-direction: column',
1029
+ 'justify-content: %s' % {'ctr': 'center', 'b': 'flex-end'}[anchor]]
1030
+ if body.get('rot'):
1031
+ try:
1032
+ degrees = float(body['rot']) / 60000.0
1033
+ css.append('rotate: %sdeg' % css_number(degrees))
1034
+ except (TypeError, ValueError):
1035
+ pass
1036
+ if css:
1037
+ out['css'] = '; '.join(css)
779
1038
  return out
780
1039
 
781
1040
 
1041
+ def instance_override(shapes, slide_part, slots, bgm, cW, cH, composites=None):
1042
+ """实例页覆盖版式:版式是骨架,实例页才是设计师最终摆定的样子。
1043
+
1044
+ 版式底图常是多个版式共用的通用底纹,实例页可能另铺主视觉大图;标题占位符的框高
1045
+ 也常被实例页放大以容纳多行。只读版式的包会让消费端拿到错的底图和装不下字的框,
1046
+ 只能自己缩字号。
1047
+ """
1048
+ ins = [s for s in shapes if s.get('part') == slide_part]
1049
+ if not ins:
1050
+ return slots, bgm
1051
+ bgm = (composites or {}).get(slide_part) or top_bleed_media(ins) or bgm
1052
+ texts = []
1053
+ for s in ins:
1054
+ b = s.get('box') or {}
1055
+ if not (b.get('w') and b.get('h')) or not shape_text(s):
1056
+ continue
1057
+ texts.append({'sz': shape_sz(s), 'box': b, 'style': slot_style(s)})
1058
+ texts.sort(key=lambda x: -x['sz'])
1059
+ # 按字号大小依次顶替版式的文字槽(版式槽已按 y 排过,字号序更贴合语义层级)
1060
+ tslots = [s for s in slots if s['type'] != 'pic']
1061
+ for slot, ins_t in zip(sorted(tslots, key=lambda s: -(s.get('sz') or 0)), texts):
1062
+ b = ins_t['box']
1063
+ slot['box'] = [round(b.get('x', 0)), round(b.get('y', 0)),
1064
+ round(b.get('w', 0)), round(b.get('h', 0))]
1065
+ slot['sz'] = ins_t['sz']
1066
+ slot.update(ins_t['style'] or {})
1067
+ return slots, bgm
1068
+
1069
+
782
1070
  def layouts_from_template(d, shapes, cW, cH):
783
1071
  """form=3:模板自己用 slideLayout 声明了页型,直接读版式层。
784
1072
 
785
1073
  拿样张聚类只能得到「样张数」个 archetype——模板往往只放 1-2 张样张,
786
- 真正的页型全在版式里。实测某飞书模板 2 张样张 / 31 个语义版式,按样张聚类
787
- 只出 2 个 archetype,消费端搭 12 页时 10 页无版式可抄,只能自己编。
1074
+ 真正的页型全在版式里。模板常见只放个位数样张却声明几十个语义版式,按样张聚类
1075
+ 只能得到「样张数」个 archetype,消费端搭页时大半无版式可抄,只能自己编。
788
1076
  """
789
1077
  by_part = defaultdict(list)
790
1078
  for s in shapes:
791
1079
  if s.get('layer') == 'layout' and s.get('ph'):
792
1080
  by_part[s['part']].append(s)
793
1081
  bg_of_layout = {}
1082
+ composites = d.get('background_composites') or {}
794
1083
  for s in shapes:
795
- if (s.get('layer') == 'layout' and s.get('kind') == 'pic'
796
- and s.get('w_pct', 0) >= 95 and s.get('h_pct', 0) >= 95):
797
- bg_of_layout.setdefault(s['part'], s.get('media'))
1084
+ if s.get('layer') == 'layout' and is_bleed(s) and s.get('media'):
1085
+ bg_of_layout[s['part']] = s['media'] # 靠后者在上层,最后一张才是看得见的
798
1086
  topo = d.get('theme_topology') or {}
799
1087
  theme_of_master = {m['master']: m.get('theme_label')
800
1088
  for m in (topo.get('per_master') or [])}
801
1089
  master_of = (d.get('reference_graph') or {}).get('master_of_layout') or {}
1090
+ # 只在版式恰好被 1 张实例页使用时才拿实例覆盖:多张实例共用一个版式时,
1091
+ # 谁都不代表版式本身,硬挑一张会把别页的构图当成页型
1092
+ lay_of_slide = (d.get('reference_graph') or {}).get('layout_of_slide') or {}
1093
+ used_n = Counter(lay_of_slide.values())
1094
+ slide_of_layout = {lp: sp for sp, lp in lay_of_slide.items() if used_n[lp] == 1}
802
1095
  default_theme = topo.get('default')
803
1096
  multi = len(topo.get('themes') or []) > 1
804
1097
 
@@ -827,7 +1120,7 @@ def layouts_from_template(d, shapes, cW, cH):
827
1120
  slots.append(row)
828
1121
  # 非满屏的图片元素(logo / 联名标 / 装饰)——它们逐版式换位置换尺寸,
829
1122
  # 必须按版式落进 slots,压成一条全局「固定位」规则就会撞标题。
830
- bgm = bg_of_layout.get(l['part'])
1123
+ bgm = composites.get(l['part']) or bg_of_layout.get(l['part'])
831
1124
  for s in shapes:
832
1125
  if s['part'] != l['part'] or s.get('kind') != 'pic' or not s.get('media'):
833
1126
  continue
@@ -842,8 +1135,16 @@ def layouts_from_template(d, shapes, cW, cH):
842
1135
  round(b.get('w', 0)), round(b.get('h', 0))]})
843
1136
  if not slots:
844
1137
  continue
845
- rows.append({'zh': clean_layout_name(l.get('name')), 'role': role_of_name(l.get('name')),
846
- 'slots': slots, 'bg_raw': bgm,
1138
+ inst = slide_of_layout.get(l['part'])
1139
+ if inst:
1140
+ slots, bgm = instance_override(
1141
+ shapes, inst, slots, bgm, cW, cH, composites)
1142
+ taken = {tuple(s['box']) for s in slots}
1143
+ decor = collect_decor(shapes, inst or l['part'], taken, (cW, cH))
1144
+ named_role = role_of_name(l.get('name'))
1145
+ rows.append({'zh': clean_layout_name(l.get('name')),
1146
+ 'role': named_role or 'content', 'role_guessed': named_role is None,
1147
+ 'slots': slots, 'decor': decor, 'bg_raw': bgm,
847
1148
  'theme': theme, 'part': l['part'],
848
1149
  'used': l.get('used_by_slides') or 0})
849
1150
 
@@ -868,14 +1169,73 @@ def layouts_from_template(d, shapes, cW, cH):
868
1169
  key = r['role'] if n == 1 else '%s-%d' % (r['role'], n)
869
1170
  m_no = re.search(r'slideLayout(\d+)\.xml$', r['part'])
870
1171
  arch.append({'name': key, 'zh': r['zh'], 'role': r['role'], 'bg': None,
871
- 'bg_raw': r['bg_raw'], 'slots': r['slots'], 'pages': [],
1172
+ 'role_guessed': r.get('role_guessed'),
1173
+ 'bg_raw': r['bg_raw'], 'slots': r['slots'],
1174
+ 'decor': r.get('decor') or [], 'pages': [],
872
1175
  'rep': None, 'rep_layout': int(m_no.group(1)) if m_no else None,
873
- 'pic_n': 0, 'confidence': 'high',
1176
+ # 版式名认不出 role 时不装作有把握:置信度降到 low,让 L 层看图定
1177
+ 'pic_n': 0, 'confidence': 'low' if r.get('role_guessed') else 'high',
874
1178
  'theme': r['theme'] if multi else None,
875
1179
  'source': 'layout:' + r['part'].split('/')[-1]})
876
1180
  return arch
877
1181
 
878
1182
 
1183
+ _QUERY = []
1184
+
1185
+
1186
+ def _load_query():
1187
+ """复用 query.py 的 OOXML→CSS 渲染,不再写第二份。"""
1188
+ if not _QUERY:
1189
+ import importlib.util
1190
+ spec = importlib.util.spec_from_file_location('_q', os.path.join(HERE, 'query.py'))
1191
+ mod = importlib.util.module_from_spec(spec)
1192
+ spec.loader.exec_module(mod)
1193
+ _QUERY.append(mod)
1194
+ return _QUERY[0]
1195
+
1196
+
1197
+ def collect_decor(shapes, part, taken_boxes, canvas, limit=10):
1198
+ """页面上撑起版式骨架、但不含文字的形状(圆形图标托、卡片、分隔线)。
1199
+
1200
+ 只给文字框的坐标,消费端看到的是「一段说明悬在半空、上方一片空白」,只能自己编
1201
+ 容器,编出来的形状与模板无关。这些形状必须进包。
1202
+ """
1203
+ q = _load_query()
1204
+ cW, cH = canvas
1205
+ out = []
1206
+ for s in shapes:
1207
+ if s.get('part') != part or s.get('kind') != 'sp':
1208
+ continue
1209
+ if any(r.get('text', '').strip()
1210
+ for para in ((s.get('text') or {}).get('paragraphs') or [])
1211
+ for r in (para.get('runs') or [])):
1212
+ continue # 有文字的已经作为 slot 出过
1213
+ b = s.get('box') or {}
1214
+ w, h = b.get('w') or 0, b.get('h') or 0
1215
+ if not (w or h):
1216
+ continue # 零尺寸形状渲染不出任何东西
1217
+ if canvas_coverage(b, cW, cH) >= FULLSCREEN_COVERAGE:
1218
+ continue # 满屏底,属 background
1219
+ box = [round(b.get('x', 0)), round(b.get('y', 0)), round(w), round(h)]
1220
+ if tuple(box) in taken_boxes:
1221
+ continue
1222
+ css = q._recipe_css(s.get('fill'), s.get('line'),
1223
+ [s.get('radius_px')] if s.get('radius_px') else [], s.get('effects'))
1224
+ # 声明要落成单行:含换行的声明会被下游的行式解析器从换行处截断,
1225
+ # 且只记 PARSE-WARN 不 FAIL,整包照常出厂——带着半条渲染不出来的 CSS
1226
+ css = [re.sub(r'\s*\n\s*', ' ', c.split('\x00')[0]).strip() for c in css if c]
1227
+ if not css:
1228
+ continue # 无填充无描边无阴影 = 看不见,不占篇幅
1229
+ out.append({'box': box, 'geom': (s.get('geom') or {}).get('prst') or 'rect',
1230
+ 'css': '; '.join(css), 'area': max(w * h, w, h)})
1231
+ # 按面积降序取前 limit 条:撑起版式的结构性形状总在最前,零星噪点自然落在截断线外,
1232
+ # 不需要再设一个「多小算噪点」的尺寸门槛(那种门槛会误杀 1px 分隔线)。
1233
+ out.sort(key=lambda d: -d['area'])
1234
+ note_truncation('装饰形状', limit, len(out), '按面积降序保留,剩下的多是零星小件',
1235
+ part.split('/')[-1])
1236
+ return out[:limit] # 同款不同位置都要留,位置本身是版式信息
1237
+
1238
+
879
1239
  def draft_layouts(d, outdir):
880
1240
  shapes = json.load(open(os.path.join(outdir, 'ref', 'shapes.json'), encoding='utf-8'))['shapes']
881
1241
  cW, cH = d['canvas']['px']
@@ -894,21 +1254,20 @@ def draft_layouts(d, outdir):
894
1254
  bg_of_slide[s['part']] = json.dumps(bg, sort_keys=True) if isinstance(bg, dict) else bg
895
1255
  layout_of_slide[s['part']] = s.get('layout')
896
1256
  # 版式层的满屏底图(form=2 常态:底图挂在 layout 上)
1257
+ composites = d.get('background_composites') or {}
897
1258
  bg_of_layout = {}
898
1259
  for s in shapes:
899
- if (s.get('layer') == 'layout' and s.get('kind') == 'pic'
900
- and s.get('w_pct', 0) >= 95 and s.get('h_pct', 0) >= 95):
901
- bg_of_layout.setdefault(s['part'], s.get('media'))
1260
+ if s.get('layer') == 'layout' and is_bleed(s) and s.get('media'):
1261
+ bg_of_layout[s['part']] = s['media']
902
1262
 
903
1263
  pages = []
904
1264
  for part, sh in sorted(by_slide.items(), key=lambda kv: slide_no(kv[0])):
905
- bg_media = None
906
- for s in sh:
907
- if s.get('kind') == 'pic' and s.get('w_pct', 0) >= 95 and s.get('h_pct', 0) >= 95:
908
- bg_media = s.get('media')
909
- break
1265
+ bg_media = top_bleed_media(sh)
910
1266
  if bg_media is None:
911
1267
  bg_media = bg_of_layout.get(layout_of_slide.get(part))
1268
+ rendered_bg = (composites.get(part)
1269
+ or composites.get(layout_of_slide.get(part))
1270
+ or bg_media)
912
1271
  texts = []
913
1272
  for s in sh:
914
1273
  if s.get('kind') != 'sp':
@@ -926,26 +1285,34 @@ def draft_layouts(d, outdir):
926
1285
  marks = [{'media': s['media'], 'box': s['box']} for s in pics
927
1286
  if s.get('media') and (s.get('box') or {}).get('w') and s.get('w_pct', 0) < 30]
928
1287
  pages.append({'part': part, 'no': slide_no(part), 'bg_media': bg_media,
1288
+ 'rendered_bg': rendered_bg,
929
1289
  'bg_color': bg_of_slide.get(part), 'texts': texts, 'pic_n': len(pics),
930
1290
  'marks': marks, 'shape_n': len(sh)})
931
1291
 
932
- def kind_of(p):
1292
+ # 页型的**角色**(封面 / 章节页 / 内容页……)不在这里判:那是看图才能下的结论,
1293
+ # 交给读得到重建图的模型。脚本只做客观归并——同一张底图 + 文字块数量相近的页
1294
+ # 归成一组,档位按本 deck 自己的分布切,不用「字号 ≥60 就是章节页」这类固定数。
1295
+ ns = sorted(len(p['texts']) for p in pages) or [0]
1296
+ q1, q2 = ns[len(ns) // 3], ns[len(ns) * 2 // 3]
1297
+
1298
+ def density_band(p):
933
1299
  n = len(p['texts'])
934
- top = p['texts'][0]['sz'] if p['texts'] else 0
935
- if p['no'] == 1:
936
- return 'cover'
937
- if n <= 3 and top >= 60:
938
- return 'section'
939
- if n >= 8 or p['pic_n'] >= 4:
940
- return 'content-dense'
941
- return 'content'
1300
+ return 0 if n <= q1 else (1 if n <= q2 else 2)
942
1301
 
943
1302
  groups = defaultdict(list)
944
1303
  for p in pages:
945
- groups[(p['bg_media'] or p['bg_color'] or 'none', kind_of(p))].append(p)
1304
+ if p['no'] == 1:
1305
+ # 首页单独成组:它是 deck 唯一的入口页,版面通常和后面任何一页都不同,
1306
+ # 并进别的组就会被代表页顶掉、坐标全丢。这只是不合并,不代表它是封面。
1307
+ groups[('__first__', -1)] = [p]
1308
+ continue
1309
+ groups[(p['bg_media'] or p['bg_color'] or 'none', density_band(p))].append(p)
946
1310
 
947
1311
  ranked = sorted(groups.items(), key=lambda kv: (-len(kv[1]), kv[1][0]['no']))
948
- kept = [g for g in ranked if len(g[1]) >= 2 or g[0][1] == 'cover'][:8]
1312
+ # 首页所在的组一定收——deck 的第一页是模板的门面,孤例也不能被名额挤掉。
1313
+ # 这只保证它进包,它是不是封面由看图的人定。
1314
+ first = [g for g in ranked if g[0][0] == '__first__']
1315
+ kept = first + [g for g in ranked if g not in first and len(g[1]) >= 2][:8 - len(first)]
949
1316
  for g in ranked: # 名额没用满就把最大的孤例页也收进来
950
1317
  if len(kept) >= 8:
951
1318
  break
@@ -954,28 +1321,40 @@ def draft_layouts(d, outdir):
954
1321
  leftover = sorted(p['no'] for g in ranked if g not in kept for p in g[1])
955
1322
 
956
1323
  archetypes = []
957
- used = Counter()
958
- for (bg_raw, kind), ps in kept:
1324
+ for gi, ((bg_raw, _band), ps) in enumerate(kept, 1):
959
1325
  rep = max(ps, key=lambda p: len(p['texts']))
960
- used[kind] += 1
961
- name = kind if used[kind] == 1 else '%s-%d' % (kind, used[kind])
962
- # 标题按「位置 + 跨度」认,不按字号——巨号数值(21%、7,869)常比标题还大
963
- band = [t for t in rep['texts']
964
- if t['box'].get('y', 1e9) < cH * 0.28 and t['box'].get('w', 0) >= cW * 0.25]
965
- title = max(band, key=lambda t: t['sz']) if band else (
1326
+ if bg_raw == '__first__':
1327
+ bg_raw = rep['bg_media'] or rep['bg_color'] or 'none'
1328
+ rendered_bg = rep.get('rendered_bg')
1329
+ if rendered_bg:
1330
+ bg_raw = rendered_bg
1331
+ name = 'layout-%d' % gi
1332
+ # 标题按「位置 + 跨度」认,不按字号——big-number 类的巨号数值常比标题还大
1333
+ # 标题 = 该页最靠上的那批文本里最宽的一块。不按「画布前 28%」这类固定比例切:
1334
+ # 版心靠下的模板会整页认不出标题。以该页自身的文本框分布定「靠上」。
1335
+ ys = sorted(t['box'].get('y', 0) for t in rep['texts'])
1336
+ y_cut = ys[max(len(ys) // 4, 0)] if ys else 0
1337
+ band = [t for t in rep['texts'] if t['box'].get('y', 1e9) <= y_cut]
1338
+ title = max(band, key=lambda t: (t['box'].get('w', 0), t['sz'])) if band else (
966
1339
  max(rep['texts'], key=lambda t: t['sz']) if rep['texts'] else None)
967
1340
  rest = [t for t in rep['texts'] if t is not title]
968
1341
  rest.sort(key=lambda t: (t['box'].get('y', 0), t['box'].get('x', 0)))
969
1342
  ordered = ([title] if title else []) + rest
970
1343
  slots = []
1344
+ note_truncation('文字槽', 6, len(ordered),
1345
+ '需要更多同类槽时,按已有同类槽的间距等距延续,不要另起一套网格'
1346
+ '——包里的坐标是模板量出来的,自创网格等于放弃这套版式', name)
971
1347
  for i, t in enumerate(ordered[:6]):
972
1348
  b = t['box']
973
1349
  if t is title:
974
1350
  role = typ = 'title'
975
- elif (title and i == 1 and t['sz'] >= 28
976
- and abs(b.get('x', 0) - title['box'].get('x', 0)) < 120
1351
+ elif (title and i == 1
1352
+ # 副标题 = 紧跟在标题下方、与标题左对齐的那一块。三个量都相对标题
1353
+ # 自身:绝对 px 门槛在大字号排版的模板上会整片认不出来。
1354
+ and abs(b.get('x', 0) - title['box'].get('x', 0)) <= title['box'].get('h', 0)
977
1355
  and 0 <= b.get('y', 0) - (title['box'].get('y', 0)
978
- + title['box'].get('h', 0)) < 220):
1356
+ + title['box'].get('h', 0))
1357
+ <= title['box'].get('h', 0) * 2):
979
1358
  role = typ = 'subtitle'
980
1359
  else:
981
1360
  role = typ = 'body'
@@ -996,7 +1375,9 @@ def draft_layouts(d, outdir):
996
1375
  'media': mk['media'],
997
1376
  'box': [round(b.get('x', 0)), round(b.get('y', 0)),
998
1377
  round(b.get('w', 0)), round(b.get('h', 0))]})
1378
+ decor = collect_decor(shapes, rep['part'], {tuple(s['box']) for s in slots}, (cW, cH))
999
1379
  archetypes.append({'name': name, 'bg': None, 'bg_raw': bg_raw, 'slots': slots,
1380
+ 'decor': decor,
1000
1381
  'pages': sorted(p['no'] for p in ps), 'rep': rep['no'],
1001
1382
  'pic_n': rep['pic_n'],
1002
1383
  'confidence': 'high' if len(ps) >= 3 else
@@ -1057,7 +1438,7 @@ def contact_sheet(outdir, cands, path):
1057
1438
  except Exception:
1058
1439
  return None
1059
1440
  cell, pad, cols = 220, 20, 4
1060
- items = cands[:12]
1441
+ items = cands # 上限由调用方定,编号与 BRIEF 表格一一对应
1061
1442
  if not items:
1062
1443
  return None
1063
1444
  rows = (len(items) + cols - 1) // cols
@@ -1137,20 +1518,32 @@ def emit_frontmatter(d, tokens, fonts, roles, anchors, gaps, ldir):
1137
1518
  edge = {}
1138
1519
  for p in pads:
1139
1520
  edge.setdefault(p['edge'], p['px'])
1140
- if edge:
1521
+ # 四边都测出来才写 spacing / safe-area。缺一边就整段不写,并在 gaps 说明——
1522
+ # 拿另一套模板的边距当默认值,会让消费端按一个从没在本模板出现过的网格排版。
1523
+ edges_full = all(edge.get(k) is not None for k in ('top', 'right', 'bottom', 'left'))
1524
+ if edges_full:
1141
1525
  L.append('spacing:')
1142
1526
  L.append(' page-padding: {top: %s, right: %s, bottom: %s, left: %s}'
1143
- % (edge.get('top', 73), edge.get('right', 90),
1144
- edge.get('bottom', 95), edge.get('left', 90)))
1145
- radii = [r for r in (d.get('radii_census') or []) if r['px'] >= 2 and r['n'] >= 6]
1146
- if radii:
1147
- top = max(radii, key=lambda r: r['n'])
1527
+ % (edge['top'], edge['right'], edge['bottom'], edge['left']))
1528
+ # rounded.card 是全局 token,只能表达全档共同的一档圆角。多个非零档位或零/非零
1529
+ # 混用时,圆角属于 layouts.md 里的局部形状事实,压成一个值会把直角容器也圆角化。
1530
+ radii = d.get('radii_census') or []
1531
+ if len(radii) == 1 and radii[0]['px'] >= 1:
1532
+ top = radii[0]
1148
1533
  L.append('rounded:')
1149
1534
  L.append(' card: %dpx' % round(top['px']))
1150
- L.append('safe-area:')
1151
- L.append(' content: {top: %s, right: %s, bottom: %s, left: %s, applies-to: [content]}'
1152
- % (edge.get('top', 73), edge.get('right', 90), edge.get('bottom', 95), edge.get('left', 90)))
1153
- L.append(' confidence: medium')
1535
+ if edges_full:
1536
+ L.append('safe-area:')
1537
+ L.append(' content: {top: %s, right: %s, bottom: %s, left: %s, applies-to: [content]}'
1538
+ % (edge['top'], edge['right'], edge['bottom'], edge['left']))
1539
+ L.append(' confidence: medium')
1540
+ else:
1541
+ gaps = list(gaps) + ['本模板没测出四边都稳定的页边距(普查到 %s),'
1542
+ '因此不给 spacing / safe-area:按各页型 slot 的实际坐标排版,'
1543
+ '不要自造统一边距。'
1544
+ % ('、'.join('%s=%s' % (k, edge[k]) for k in
1545
+ ('top', 'right', 'bottom', 'left') if edge.get(k) is not None)
1546
+ or '一边都没有')]
1154
1547
  L.append('anchors:')
1155
1548
  for aid, typ, desc in anchors:
1156
1549
  L.append(' - {id: %s, type: %s, desc: "%s"}' % (aid, typ, desc))
@@ -1160,12 +1553,161 @@ def emit_frontmatter(d, tokens, fonts, roles, anchors, gaps, ldir):
1160
1553
  write(os.path.join(ldir, 'frontmatter.yaml'), '\n'.join(L) + '\n')
1161
1554
 
1162
1555
 
1163
- def emit_layouts(archetypes, ldir):
1556
+ def draft_flow(a, facts, canvas):
1557
+ """从结构事实推出「区带」草案:一页 = 若干竖直区带,高度由内容决定。
1558
+
1559
+ 绝对坐标只能表达「模板样张那份内容摆在哪」。真实内容长度不同,上面的区带一变高,
1560
+ 下面的就该整体下移——这件事在一张坐标表里表达不出来,只能靠消费端自己算,而它
1561
+ 算错的方向有两个:估小了压穿下一块,估大了留一片空。
1562
+
1563
+ 这里只出草案,最终用绝对还是流式由看得到重建图的人定。
1564
+ """
1565
+ cW, cH = canvas
1566
+ # 装饰件也算进来:很多模板的版式层只有几个占位符,真正撑起版面的是卡片容器
1567
+ # (在 decor 里)。只看 slots 会把一页的主体结构整个漏掉。
1568
+ items = [s for s in a['slots'] if s.get('box')]
1569
+ items += [{'role': 'container', 'type': 'decor', 'box': dcr['box'], 'css': dcr.get('css')}
1570
+ for dcr in (a.get('decor') or [])]
1571
+ if len(items) < 2:
1572
+ return None
1573
+ items.sort(key=lambda s: (s['box'][1], s['box'][0]))
1574
+ gaps = [items[i + 1]['box'][1] - (items[i]['box'][1] + items[i]['box'][3])
1575
+ for i in range(len(items) - 1)]
1576
+ pos = [g for g in gaps if g > 0]
1577
+ if not pos:
1578
+ return None
1579
+ # 区带边界 = 间距分布里的最大空档。同一区带内部的间距(网格行距之类)总是明显
1580
+ # 小于区带之间的间距,用本页自己的分布切,不设固定阈值。
1581
+ cut = _gap_cut(pos, min(pos), max(pos)) if len(pos) > 1 else max(pos) + 1
1582
+ regions, cur = [], [items[0]]
1583
+ for i, g in enumerate(gaps):
1584
+ if g >= cut:
1585
+ regions.append(cur)
1586
+ cur = []
1587
+ cur.append(items[i + 1])
1588
+ regions.append(cur)
1589
+
1590
+ out = []
1591
+ for reg in regions:
1592
+ if not reg:
1593
+ continue
1594
+ # 同一区带里 y 接近的算一行;每行元素数一致且 >1 就是网格
1595
+ rows, cr = [], [reg[0]]
1596
+ for s in reg[1:]:
1597
+ if abs(s['box'][1] - cr[-1]['box'][1]) <= max(s['box'][3], 1) * 0.5:
1598
+ cr.append(s)
1599
+ else:
1600
+ rows.append(cr)
1601
+ cr = [s]
1602
+ rows.append(cr)
1603
+ widths = {len(r) for r in rows}
1604
+ if len(rows) >= 1 and widths == {len(rows[0])} and len(rows[0]) > 1:
1605
+ cols = len(rows[0])
1606
+ xs = sorted(s['box'][0] for s in rows[0])
1607
+ col_gap = round((xs[1] - xs[0]) - rows[0][0]['box'][2]) if cols > 1 else 0
1608
+ row_gap = 0
1609
+ if len(rows) > 1:
1610
+ row_gap = round(rows[1][0]['box'][1]
1611
+ - (rows[0][0]['box'][1] + rows[0][0]['box'][3]))
1612
+ out.append({'kind': 'grid', 'cols': cols, 'gap': [max(col_gap, 0), max(row_gap, 0)],
1613
+ 'items': rows[0]})
1614
+ elif len(rows) == len(reg):
1615
+ # 每行一个元素 = 真的竖着排
1616
+ inner = 0
1617
+ if len(reg) > 1:
1618
+ inner = round(reg[1]['box'][1] - (reg[0]['box'][1] + reg[0]['box'][3]))
1619
+ out.append({'kind': 'stack', 'gap': max(inner, 0), 'items': reg})
1620
+ else:
1621
+ # 每行元素数不一致(比如左列两张、右列一张跨两行)。硬说成 stack 会让消费端
1622
+ # 以为它们是竖排的,比不给还糟。如实说这块推不出规整结构,按坐标摆。
1623
+ out.append({'kind': 'free', 'items': reg})
1624
+ if len(out) < 2:
1625
+ return None
1626
+ lefts = [s['box'][0] for s in items]
1627
+ rights = [s['box'][0] + s['box'][2] for s in items]
1628
+ return {'top': items[0]['box'][1], 'margin': [min(lefts), cW - max(rights)],
1629
+ 'gap': round(cut), 'regions': out}
1630
+
1631
+
1632
+ def structure_facts(archetypes, d, shapes):
1633
+ """每个页型的**结构事实**:栅格、垂直间距序列、容器样式配方、样张里的实际字数。
1634
+
1635
+ 这些是判「该用绝对坐标还是流式」的依据,脚本只测不判:
1636
+ - 栅格拟合好不好,决定这页是不是一个规整的多列区带
1637
+ - 垂直间距序列里的突变点,就是区带的边界(网格内部 24、区带之间 110)
1638
+ - 样张字数说明这个框是按几行内容设计的——框高本身看不出这件事
1639
+ """
1640
+ q = _load_query()
1641
+ by_part = defaultdict(list)
1642
+ for s in shapes:
1643
+ by_part[s.get('part')].append(s)
1644
+
1645
+ # 容器样式配方:跨全档聚类一次,记出现次数与跨页数,供判断「哪些是共性风格」
1646
+ groups = {}
1647
+ for s in shapes:
1648
+ fill, line, fx = s.get('fill'), s.get('line'), s.get('effects')
1649
+ if not fill and not line and not fx:
1650
+ continue
1651
+ if isinstance(fill, dict) and fill.get('type') == 'image':
1652
+ continue
1653
+ k = q._sig(fill, line, fx)
1654
+ if k[0] == 'none' and k[1] == 'none' and not k[2]:
1655
+ continue
1656
+ g = groups.setdefault(k, {'n': 0, 'parts': set(), 'radii': [],
1657
+ 'fill': fill, 'line': line, 'fx': fx, 'shapes': set()})
1658
+ g['n'] += 1
1659
+ g['parts'].add(s.get('part'))
1660
+ g['radii'].append(s.get('radius_px') or 0)
1661
+ g['shapes'].add(id(s))
1662
+ ranked = sorted(groups.values(), key=lambda g: -g['n'])
1663
+ recipe_id = {}
1664
+ recipes = []
1665
+ for i, g in enumerate(ranked, 1):
1666
+ rid = 'r%d' % i
1667
+ css = [re.sub(r'\s*\n\s*', ' ', c.split('\x00')[0]).strip()
1668
+ for c in q._recipe_css(g['fill'], g['line'], g['radii'], g['fx']) if c]
1669
+ recipes.append({'id': rid, 'n': g['n'], 'pages': len(g['parts']),
1670
+ 'css': '; '.join(css)})
1671
+ for sid in g['shapes']:
1672
+ recipe_id[sid] = rid
1673
+
1674
+ grids = (d.get('spacing_candidates') or {}).get('grids') or []
1675
+ grid_by_part = defaultdict(list)
1676
+ for gd in grids:
1677
+ grid_by_part[gd.get('part')].append(gd)
1678
+
1679
+ out = {}
1680
+ for a in archetypes:
1681
+ part = None
1682
+ if a.get('source', '').startswith('layout:'):
1683
+ part = 'ppt/slideLayouts/' + a['source'].split(':', 1)[1]
1684
+ elif a.get('rep'):
1685
+ part = 'ppt/slides/slide%d.xml' % a['rep']
1686
+ boxes = [s['box'] for s in a['slots']] + [x['box'] for x in (a.get('decor') or [])]
1687
+ boxes.sort(key=lambda b: b[1])
1688
+ gaps = [boxes[i + 1][1] - (boxes[i][1] + boxes[i][3]) for i in range(len(boxes) - 1)]
1689
+ chars = [(s['box'], len(s.get('txt') or '')) for s in a['slots'] if s.get('txt')]
1690
+ used = []
1691
+ for s in by_part.get(part, []):
1692
+ rid = recipe_id.get(id(s))
1693
+ if rid and rid not in used:
1694
+ used.append(rid)
1695
+ out[a['name']] = {'grids': grid_by_part.get(part) or [], 'gaps': gaps,
1696
+ 'chars': chars, 'recipes': used}
1697
+ return out, recipes
1698
+
1699
+
1700
+ def emit_layouts(archetypes, ldir, busy_hints=None, facts=None, recipes=None):
1164
1701
  prefilled = sum(1 for a in archetypes if a.get('zh'))
1165
- L = ['# 只改 names bg_rules 两段(都是扁平键值,改完 package.py 自动并回各页型)。',
1702
+ L = ['# 判断单草案 —— package.py 读它产出 layouts.md,deck 的版式坐标从 layouts.md 读。',
1703
+ '# 只改 names / roles / bg_rules 三段(都是扁平键值,改完 package.py 自动并回各页型)。',
1166
1704
  '# 下面 layouts 段是普查数值,一个字都不要动——改它容易连带删掉 slots/confidence。']
1167
1705
  if prefilled:
1168
1706
  L.append('# names 已按模板自带的版式名填好 %d 条,读一遍确认表意即可,通常不用改。' % prefilled)
1707
+ if recipes:
1708
+ L.append('# 容器样式配方(按出现次数排;跨页数多 = 共性风格,只在一处出现的多半不是):')
1709
+ for r in recipes[:8]:
1710
+ L.append('# %s 出现 %d 次 / 跨 %d 处 %s' % (r['id'], r['n'], r['pages'], r['css']))
1169
1711
  L.append('names:')
1170
1712
  for a in archetypes:
1171
1713
  if a.get('zh'):
@@ -1174,6 +1716,19 @@ def emit_layouts(archetypes, ldir):
1174
1716
  else:
1175
1717
  L.append(' %s: TODO中文名(代表页 %s,共 %d 页)'
1176
1718
  % (a['name'], a['rep'], len(a['pages'])))
1719
+ # 角色(封面 / 章节页 / 内容页……)是看图才能下的结论,脚本不猜。模板自己按页型
1720
+ # 命名时用它的标注,否则连同客观事实一起摆出来,由看得到重建图的你来定。
1721
+ need_role = [a for a in archetypes if not a.get('role')]
1722
+ if need_role:
1723
+ L.append('roles: # 取值 cover|section|content|quote|closing|blank|custom')
1724
+ for a in need_role:
1725
+ szs = sorted({round(s['sz']) for s in a['slots'] if s.get('sz')}, reverse=True)
1726
+ L.append(' %s: TODO角色 # 代表页 %s,共 %d 页;文字块 %d 个,字号 %s;'
1727
+ '图片 %d 张%s'
1728
+ % (a['name'], a['rep'], len(a['pages']),
1729
+ len([s for s in a['slots'] if not s.get('asset')]),
1730
+ '/'.join(str(x) for x in szs[:5]) or '未声明',
1731
+ a.get('pic_n') or 0, ';有满屏底图' if a.get('bg_raw') else ''))
1177
1732
  # 禁放区是**背景图**的属性,不是页型的属性——按背景资产分组,页型再多也不涨
1178
1733
  bgs = []
1179
1734
  for a in archetypes:
@@ -1184,26 +1739,108 @@ def emit_layouts(archetypes, ldir):
1184
1739
  for bg in bgs:
1185
1740
  users = [a['name'] for a in archetypes if a['bg'] == bg]
1186
1741
  L.append(' %s: # 用它的页型:%s' % (bg, ', '.join(users)))
1187
- L.append(' text_safe: TODO安全文字区[x,y,w,h],按这张背景的主体避让后填写')
1742
+ hint = (busy_hints or {}).get(bg)
1743
+ if hint:
1744
+ L.append(' # 图像局部对比度:中位 %s、九分位 %s;%s'
1745
+ % (hint['median'], hint['p90'],
1746
+ ('更花的一片在 %s' % hint['busy']) if hint.get('busy')
1747
+ else hint.get('why', '')))
1748
+ # text_safe 不是判断题:模板自己已经把文字放在哪儿写死了。取用这张背景的
1749
+ # 所有页型的槽与装饰件的外接并集即可——让人看图猜只会猜得更松,把模板从不
1750
+ # 放字的区域也划进安全区,这个字段就白设了。
1751
+ boxes = [s['box'] for a in archetypes if a['bg'] == bg for s in a['slots']] + \
1752
+ [dcr['box'] for a in archetypes if a['bg'] == bg for dcr in (a.get('decor') or [])]
1753
+ if boxes:
1754
+ x0 = min(b[0] for b in boxes)
1755
+ y0 = min(b[1] for b in boxes)
1756
+ x1 = max(b[0] + b[2] for b in boxes)
1757
+ y1 = max(b[1] + b[3] for b in boxes)
1758
+ L.append(' text_safe: [%d, %d, %d, %d] # 由该背景各页型的槽位并集算出'
1759
+ % (x0, y0, x1 - x0, y1 - y0))
1760
+ else:
1761
+ L.append(' text_safe: TODO安全文字区[x,y,w,h](该背景下没有任何槽位可依据)')
1188
1762
  L.append(' avoid: TODO禁放区列表;无禁放区写 [],有则写 [{box: [x,y,w,h], reason: "..."}]')
1189
1763
  L.append(' pairing_rule: "TODO这张背景上标题/正文/图表要避让哪些区域"')
1190
1764
  L.append('layouts:')
1191
1765
  for a in archetypes:
1766
+ fx = (facts or {}).get(a['name']) or {}
1767
+ if fx:
1768
+ # 结构事实:判「这页该用绝对坐标还是流式」的依据。脚本只测不判。
1769
+ for gd in (fx.get('grids') or [])[:2]:
1770
+ c, r = gd.get('cols') or {}, gd.get('rows') or {}
1771
+ L.append(' # 栅格:%s 列%s%s' % (
1772
+ c.get('n'), ' @%gpx 步距方差 %.2f' % (c.get('pitch') or 0, c.get('sd') or 0)
1773
+ if c.get('regular') else '(列不规整)',
1774
+ ',行 %s' % (('%d @%gpx' % (r.get('n') or 0, r.get('pitch') or 0))
1775
+ if r.get('regular') else '不规整')))
1776
+ if fx.get('gaps'):
1777
+ L.append(' # 垂直间距:%s(突变处即区带边界)'
1778
+ % '、'.join(str(int(g)) for g in fx['gaps'][:10]))
1779
+ if fx.get('chars'):
1780
+ L.append(' # 样张字数:%s'
1781
+ % '、'.join('%s=%d字' % (b, n) for b, n in fx['chars'][:6]))
1782
+ if fx.get('recipes'):
1783
+ L.append(' # 命中配方:%s' % '、'.join(fx['recipes'][:4]))
1784
+ # 槽与槽在坐标上重叠:PPT 里占位符互相压是常态(文字 valign 居中、样张只有一行,
1785
+ # 看不出来),照抄坐标做成 HTML 后内容一变长就撞。实测封面 title 框比 subtitle
1786
+ # 的顶还低 41px,两行标题直接压在副标题上。这里只报事实,怎么让开由你定。
1787
+ ov = slot_overlaps(a.get('slots') or [])
1788
+ if ov:
1789
+ L.append(' # 槽位重叠:%s(模板里靠文字居中不显形,内容变长会撞)'
1790
+ % '、'.join(ov[:3]))
1192
1791
  L.append(' %s:' % a['name'])
1193
- L.append(' role: %s' % a['name'].split('-')[0])
1792
+ if a.get('role'):
1793
+ L.append(' role: %s' % a['role'])
1194
1794
  if a['bg']:
1195
1795
  L.append(' background: %s' % a['bg'])
1796
+ fl = a.get('flow')
1797
+ if fl:
1798
+ L.append(' # ↓ flow 与 slots 二选一:内容长度会变的页用 flow(区带依次排、'
1799
+ '高度由内容定、下面的自动被推下去),构图固定的页用 slots。删掉不要的那个。')
1800
+ L.append(' flow:')
1801
+ L.append(' top: %d' % fl['top'])
1802
+ L.append(' margin: [%d, %d]' % tuple(fl['margin']))
1803
+ L.append(' gap: %d' % fl['gap'])
1804
+ L.append(' regions:')
1805
+ for r in fl['regions']:
1806
+ if r['kind'] == 'grid':
1807
+ L.append(' - kind: grid')
1808
+ L.append(' cols: %d' % r['cols'])
1809
+ L.append(' gap: [%d, %d]' % tuple(r['gap']))
1810
+ elif r['kind'] == 'free':
1811
+ L.append(' - kind: free # 推不出规整结构,按 slots 的坐标摆')
1812
+ else:
1813
+ L.append(' - kind: stack')
1814
+ L.append(' gap: %d' % r['gap'])
1815
+ L.append(' items:')
1816
+ for s in r['items']:
1817
+ # free 区带按坐标摆,而 slots 会被删掉,所以坐标必须写在这里
1818
+ bx = ', box: %s' % s['box'] if r['kind'] == 'free' else ''
1819
+ if s.get('type') == 'decor':
1820
+ L.append(' - {role: container%s, css: "%s"}'
1821
+ % (bx, (s.get('css') or '').replace('"', "'")))
1822
+ continue
1823
+ extra = bx
1824
+ if s.get('css') is not None:
1825
+ # CSS 串一律加引号:里面的逗号/冒号在 flow map 里是分隔符
1826
+ extra += ', css: "%s"' % str(s['css']).replace('"', "'")
1827
+ if s.get('asset'):
1828
+ extra += ', asset: %s' % s['asset']
1829
+ L.append(' - {role: %s, type: %s%s}' % (s['role'], s['type'], extra))
1196
1830
  L.append(' slots:')
1197
1831
  for s in a['slots']:
1198
1832
  extra = ''
1199
1833
  if s.get('asset'):
1200
1834
  extra += ', asset: %s' % s['asset']
1201
- for k in ('size', 'weight', 'color', 'align', 'valign'):
1202
- if s.get(k) is not None:
1203
- v = s[k]
1204
- extra += ', %s: %s' % (k, '"%s"' % v if k == 'color' else v)
1835
+ if s.get('css') is not None:
1836
+ extra += ', css: "%s"' % str(s['css']).replace('"', "'")
1205
1837
  L.append(' - {role: %s, box: %s, type: %s%s}'
1206
1838
  % (s['role'], s['box'], s['type'], extra))
1839
+ if a.get('decor'):
1840
+ L.append(' decor:')
1841
+ for dcr in a['decor']:
1842
+ L.append(' - {box: %s, geom: %s, css: "%s"}'
1843
+ % (dcr['box'], dcr['geom'], dcr['css'].replace('"', "'")))
1207
1844
  L.append(' confidence: %s' % a.get('confidence', 'medium'))
1208
1845
  write(os.path.join(ldir, 'layouts.yaml'), '\n'.join(L) + '\n')
1209
1846
 
@@ -1211,9 +1848,8 @@ def emit_layouts(archetypes, ldir):
1211
1848
  def emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, ldir):
1212
1849
  """design.md 正文。
1213
1850
 
1214
- 每条规则只出现一次——旧版把同一条在 Fast Path / Usage / Background Safety /
1215
- Hard Rules 各写一遍(实测「内容页背景按 background 取」出现 7 次、「颜色只用
1216
- colors」6 次),措辞还都不一样,消费端无法判断哪份权威。
1851
+ 每条规则只出现一次——同一条散在 Fast Path / Usage / Background Safety /
1852
+ Hard Rules 各写一遍时措辞必然漂移,消费端无法判断哪份权威。
1217
1853
  坐标、字号、色值、资产位置的权威都在 layouts.md;本文件只给色板、字体栈与纪律。
1218
1854
  """
1219
1855
  canvas = d['canvas']['px']
@@ -1224,23 +1860,51 @@ def emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, l
1224
1860
 
1225
1861
  L = ['## Overview', '',
1226
1862
  'TODO: 两三句话讲清这套模板的性格与适用场景——看过联系表和页面重建图之后再写。', '']
1227
- L.append(('模板自带 %d 种版式,页型、坐标、字号、色值都直读自版式层。'
1863
+ L.append(('模板自带 %d 种版式,页型、坐标和 CSS 样式都直读自版式层。'
1228
1864
  % len(archetypes)) if (d.get('form_hint') or {}).get('form') == 3 else
1229
1865
  ('%d 页样张归纳出 %d 种页型。' % (d['counts']['slides'], len(archetypes))))
1230
1866
  L += ['', '## Usage', '',
1231
- '搭一页 PPT 三步,前两步的数据都在 %s:' % sidecar, '']
1232
- L += ['1. **挑页型** —— %s 里按用途选一个 archetype(清单见下面 Layouts 段)。'
1233
- '页数多于页型时,挑最接近的一个原样套用它的 slot,多出来的槽删掉。' % sidecar,
1234
- '2. **按 slot 落元素** —— 每个 slot 渲染成一个绝对定位元素:`box` '
1235
- '`[x, y, w, h]`(%dx%d 画布上的绝对像素),字号取 slot 的 `size`,'
1236
- '字重取 `weight`,颜色取 `color`,对齐取 `align` / `valign`。'
1867
+ '搭一页 PPT 六步,中间四步的数据都在 %s:' % sidecar, '']
1868
+ L += ['1. **定画布** —— 舞台按 `layouts.md` `canvas` 设成 %d×%d,'
1869
+ '别套用默认尺寸:源模板的长宽比不一定是 16:9,套错了整页坐标全偏。'
1870
+ '舞台尺寸改不了时,整体等比缩放 `min(舞台宽/%d, 舞台高/%d)` 后居中留白——'
1871
+ '逐轴拉伸会把圆压成椭圆、把字挤扁。' % (canvas[0], canvas[1], canvas[0], canvas[1]),
1872
+ '2. **挑页型** —— %s 里按用途选一个 archetype(清单见下面 Layouts 段)。'
1873
+ '页数多于页型时,挑最接近的一个原样套用它的 slot:用不到的槽删掉,'
1874
+ '内容比槽多就按同类槽的间距等距加,**坐标一律沿用该页型给的那套,不要自己另起网格**。'
1875
+ % sidecar,
1876
+ '3. **按页型给的形态落元素** —— 页型给 `flow` 就用流式,给 `slots` 就用绝对,'
1877
+ '两者只会出现一个。'
1878
+ '**flow**:整块用一个纵向 flex 容器,`top` 是它的起始 y,`margin` 是左右边距,'
1879
+ '`gap` 是区带之间的间距;`regions` 从上往下依次排,**每个区带的高度由它自己的'
1880
+ '内容决定,不要写死高度**——上面的区带内容变多时,下面的自然被推下去,这正是'
1881
+ '这套表达要解决的事。区带内部:`kind: grid` 用 `grid-template-columns: repeat(cols, 1fr)` '
1882
+ '配 `gap: [行间距, 列间距]`;`kind: stack` 用纵向 flex 配 `gap`;`kind: free` '
1883
+ '按该页型 `slots` 里的坐标绝对定位。每个 `role: container` 的项是容器,把它的 '
1884
+ '`css` 逐项原样写进 style,内容放进去;其中没有 `border-radius` 就按 `0`,'
1885
+ '不得自行补圆角。',
1886
+ '4. **按 slot 落元素(页型给的是 slots 时)** —— 每个 slot 渲染成一个绝对定位元素:`box` 是 '
1887
+ '`[x, y, w, h]`(%dx%d 画布上的绝对像素),机械展开成 `left/top/width/height`;'
1888
+ 'slot 的 `css` 是模板排版属性已转译好的声明串,原样写进 style,不要另选字号、'
1889
+ '内边距、颜色或对齐。'
1237
1890
  '带 `asset` 的 slot 是图片元素(logo、角标),把该资产放在它自己的 `box` 里;'
1238
1891
  '这个页型没有 `asset` 槽,这一页就不出现该资产。' % (canvas[0], canvas[1]),
1239
- '3. **配色与字体** —— 色板见下面 Colors 段,字体栈与 `@import` 见 Typography 段。']
1892
+ '5. **铺装饰几何** —— 页型的 `decor` 是这一页的图形骨架(图标托底的圆、'
1893
+ '卡片、分隔线):每条渲染成一个绝对定位空元素,`box` 给位置,`css` 逐项原样写进 '
1894
+ 'style;没有 `border-radius` 就按 `0`。只有 `geom: ellipse` 另加 '
1895
+ '`border-radius: 50%`。它们压在背景之上、slot 之下,'
1896
+ '落在 slot 上的图标正是靠它们托住。',
1897
+ '6. **落实全局设计** —— `design.md` frontmatter 的 `colors`、`typography`、'
1898
+ '`spacing`、`rounded`、`components` 是全局 token;用 CSS variables、类名或内联'
1899
+ '样式承载。局部 slot / decor 的 `css` 优先,不能再解释成另一套视觉系统。'
1900
+ '字体使用 Typography 的完整栈与降级,不在运行时安装字体或依赖。']
1240
1901
  if assets:
1241
1902
  L += ['', '资产文件(背景由页型的 `background` 字段指定,'
1242
1903
  '图片资产的位置由该页型 `slots` 里带 `asset` 的槽给出):', '',
1243
- '{{ASSET_TABLE}}']
1904
+ '{{ASSET_TABLE}}', '',
1905
+ '将包内 `assets/` 复制到项目内相对目录,再引用复制后的路径;最终 HTML 不引用'
1906
+ '抽取工作目录或本机绝对路径。附件只提供 `assetRoot` / `assetPaths` 时,把'
1907
+ '`assetRoot` 当作不透明前缀,只拼接清单中声明的相对路径。']
1244
1908
  L += ['', '文字与容器的外接矩形落在该页型 `background` 对应的 `text_safe` 内,'
1245
1909
  '避开 `avoid` 列出的区域(两者都在 %s 的 `backgrounds` 段)。内容装不下时换页型或拆页。'
1246
1910
  % sidecar, '',
@@ -1255,7 +1919,8 @@ def emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, l
1255
1919
  ',源为商业/内部字体无 web 分发源,按气质降级到 %s' % f['stack'][1]
1256
1920
  if len(f['stack']) > 1 else ''))
1257
1921
  L += ['', '字号轴:' + '、'.join('%s %dpx' % (k, round(v['sz_px'])) for k, v in roles.items())
1258
- + '。slot 自带 `size` 时以 slot 为准;层级在轴上没有的,复用最接近的一档。', '',
1922
+ + '。slot 自带 `css` 时以其中的 `font-size` 为准;没有 slot CSS 的新增层级,'
1923
+ '复用轴上最接近的一档。', '',
1259
1924
  '字体加载(**HARD REQUIREMENT:下面这行 @import 原样写入全局样式首行,禁止替换为 '
1260
1925
  'fonts.googleapis.com 或其他域**):', '', '```', imp, '```', '',
1261
1926
  '镜像只保证 wght 400 一档,更粗的字重由浏览器合成,字重不能作为唯一区分手段;'
@@ -1268,14 +1933,26 @@ def emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, l
1268
1933
  if any(a['role'] == 'content' for a in assets):
1269
1934
  L.append('- 内容页的背景由该页型的 `background` 字段指定,整幅铺满。')
1270
1935
  if logo:
1271
- L.append('- `%s` 的位置来自各页型 `slots` 里 `role: logo` 的 `box`——原样使用该文件,'
1272
- '保持原比例。' % logo['id'])
1936
+ # 点名哪几个页型带 logo。只说「位置去 slots 里查」的话,读起来像是每个页型都有
1937
+ # 这个槽、去查就行——而「不放」是靠该页型 slots 里缺这一项来表达的,要消费端
1938
+ # 自己做否定式推理才能得出。正面点名比让它去发现缺席可靠。
1939
+ with_logo = [a['name'] for a in archetypes
1940
+ if any(str(s.get('asset') or '') == logo['id'] for s in a['slots'])]
1941
+ L.append('- `%s` 只出现在这些页型上:%s;其余页型不放。位置取该页型 `slots` 里 '
1942
+ '`role: logo` 那一项的 `box`,原样使用该文件、保持原比例。'
1943
+ % (logo['id'], '、'.join('`%s`' % x for x in with_logo) or '(无)'))
1273
1944
  L += ['- 坐标、字号、色值、资产位置以 %s 为准;本文件的 Colors / Typography 是可用值的清单。'
1274
1945
  % sidecar,
1275
- '- 内容语义色(增长绿、下降红之类)本模板没有:用色板内颜色的深浅或透明度表达正负。',
1946
+ '- 强调色族以 Colors 和 %s 的 slot CSS 为准,不得自行新增第二强调色。'
1947
+ % sidecar,
1948
+ '- 允许新增中性色、低彩度辅助色或局部语义色来表达正负、风险、警告、状态、图表序列,'
1949
+ '但必须保持辅助层级;只要新色通过高饱和、高对比、大面积或跨页重复获得主视觉权重,'
1950
+ '或被用于标题、关键数字、图表主序列、卡片底色或渐变,就属于新的强调色,改用模板'
1951
+ '强调色族的深浅、透明度,或改用线型、纹理、标签区分。',
1952
+ '- 交付前逐页检查:色板、字体、版式、背景、资产和本段规则均来自本风格包;'
1953
+ '页面无资源加载失败、内容溢出或画幅裁切。',
1276
1954
  '- 本包里的数值就是普查结果,照用即可,无需重新统计颜色、字体或版式。',
1277
1955
  '- 风格包以文本形式(zip 摘要等)到手时,直接用摘要里 design.md / layouts.md 的文本。',
1278
- '- TODO: 补 1-2 条这套模板特有的硬规则(看过重建图之后写,例如主色只许用在哪类元素)。',
1279
1956
  '', '## Exceptions', '']
1280
1957
  if exceptions:
1281
1958
  L += ['- ' + e for e in exceptions]
@@ -1287,31 +1964,57 @@ def emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, l
1287
1964
 
1288
1965
  def emit_brief(d, ctx, ldir):
1289
1966
  (tokens, rest, fonts, roles, assets, rejected, todos, archetypes, cands, sheet,
1290
- leftover, lsheet) = ctx
1967
+ leftover, lsheet, sheet_n) = ctx
1291
1968
  canvas = d['canvas']['px']
1292
- L = ['# 抽取简报(草案已生成,读完这一页就能改)', '',
1969
+ L = ['# 抽取简报(第 1/3 步产物;改完草案跑 package.py 出包)', '',
1293
1970
  '源:`%s` 画布 %dx%d %d 页 / %d 版式 主题 %s form=%s'
1294
1971
  % (d['source']['filename'], canvas[0], canvas[1], d['counts']['slides'],
1295
1972
  d['counts']['layouts'], d['theme_topology']['themes'],
1296
1973
  d['form_hint']['form']), '',
1297
1974
  '## 待判断(草案里已标 TODO,逐条改掉)', '']
1298
- base_todos = ['给风格起名:`manifest.yaml` 的 name / name_zh / description(看两张图定气质)',
1299
- '`layouts.yaml` 顶部 `names:` 一段填 %d 个中文页型名(看 layout-sheet.png,'
1300
- '一次改完;下面 layouts 段不要动)' % len(archetypes),
1301
- '`body.md` Overview 与 Hard Rules 末条(Colors 用途列草案已填好,觉得不对再改)']
1302
- for t in base_todos + todos:
1975
+ # 待判断清单从草案实时扫 TODO 生成,不写死:写死的清单会和草案对不上——
1976
+ # 既漏掉后加的段(模型读到一半才发现还有活),又在草案已预填时还催人去填。
1977
+ HINT = {'manifest.yaml': '看两张图定气质',
1978
+ 'layouts.yaml': '看 layout-sheet.png;layouts 段本身不要动',
1979
+ 'body.md': 'Colors 用途列草案已填好,觉得不对再改'}
1980
+ for fn in ('manifest.yaml', 'body.md', 'layouts.yaml', 'frontmatter.yaml'):
1981
+ path = os.path.join(ldir, fn)
1982
+ if not os.path.exists(path):
1983
+ continue
1984
+ keys = []
1985
+ for line in open(path, encoding='utf-8'):
1986
+ if 'TODO' not in line:
1987
+ continue
1988
+ m = re.match(r'\s*[-#]?\s*([\w-]+):', line)
1989
+ keys.append(m.group(1) if m else line.strip()[:24])
1990
+ if not keys:
1991
+ continue
1992
+ seen, uniq = set(), []
1993
+ for k in keys:
1994
+ if k not in seen:
1995
+ seen.add(k)
1996
+ uniq.append(k)
1997
+ hint = HINT.get(fn)
1998
+ L.append('- `%s` %d 处:%s%s'
1999
+ % (fn, len(keys), '、'.join(uniq[:6]) + ('…' if len(uniq) > 6 else ''),
2000
+ '(%s)' % hint if hint else ''))
2001
+ for t in todos:
1303
2002
  L.append('- ' + t)
1304
2003
  L += ['', '## 联系表(一次看完所有候选图)', '',
1305
- '`l-out/contact-sheet.png` —— 编号对应下表;看完再决定 logo / 封面归属。' if sheet
2004
+ '`l-out/contact-sheet.png` —— 图格编号对应下表前几行;看完再决定 logo / 封面归属。' if sheet
1306
2005
  else '(Pillow 不可用,未生成联系表;逐张看 `media-out/`)', '',
1307
2006
  '| # | 文件 | 尺寸 | 出现 | 满屏 | 页 | 草案判定 |', '|---|---|---|---|---|---|---|']
1308
2007
  decided = {a['src']['file']: a['id'] for a in assets}
1309
2008
  why = {c['file']: r for c, r in rejected}
1310
- for i, c in enumerate(cands[:12], 1):
2009
+ for i, c in enumerate(cands, 1):
1311
2010
  L.append('| %d | `%s` | %sx%s | %d | %s | %s | %s |' % (
1312
2011
  i, c['file'], c['probe'].get('w') or '?', c['probe'].get('h') or '?', c['n'],
1313
2012
  'Y' if c['fullscreen'] else '', ','.join(map(str, c['slides'][:6])) or 'layout',
1314
2013
  decided.get(c['file']) or ('✗ ' + why.get(c['file'], '未采纳'))))
2014
+ if len(cands) > sheet_n:
2015
+ L.append('')
2016
+ L.append('拼版图只含前 %d 张(第 %d 行之后的没有图格)。要看后面某张,'
2017
+ '按文件名直接看 `media-out/`。' % (sheet_n, sheet_n))
1315
2018
  L += ['', '## 颜色(草案 token 已写进 frontmatter.yaml)', '',
1316
2019
  '| token | hex | 出现 |', '|---|---|---|']
1317
2020
  for name, r in tokens:
@@ -1363,31 +2066,88 @@ def main(argv=None):
1363
2066
  tokens, rest, rows = draft_colors(d, cusage)
1364
2067
  fonts = draft_fonts(d)
1365
2068
  archetypes, pages, leftover = draft_layouts(d, outdir)
2069
+ # 封面底图:form=3 的页型键就是角色名(cover/section/...),直接按名字取。
2070
+ # form=2 按样张聚类,键是 layout-1..N,永远匹配不上 'cover'——实测 vo-lite 因此
2071
+ # 一张 role: cover 都没有,封面主视觉被标成 bg-content-1,消费端拿不到封面资产,
2072
+ # design.md 的「封面底图必用 cover 资产」这条硬规则无从满足。回退到覆盖第 1 页的
2073
+ # 那个页型:deck 的第 1 页就是封面,这是版式无关的事实。
1366
2074
  cover_media = next((a['bg_raw'] for a in archetypes if a['name'] == 'cover'), None)
1367
- bg_needed = {a['bg_raw'] for a in archetypes if a['bg_raw'] and a['bg_raw'].startswith('ppt/media')}
1368
- bg_under = {p['no']: p['bg_media'] for p in pages}
1369
- assets, rejected, todos, alias = draft_assets(d, outdir, bg_needed, cover_media, bg_under)
2075
+ if not cover_media:
2076
+ cover_media = next((a['bg_raw'] for a in archetypes
2077
+ if 1 in (a.get('pages') or ())), None)
2078
+ exported_media = {m['media'] for m in d.get('media', []) if m.get('exported')}
2079
+ bg_needed = {a['bg_raw'] for a in archetypes if a['bg_raw'] in exported_media}
2080
+ bg_under = {p['no']: p.get('rendered_bg') or p['bg_media'] for p in pages}
2081
+ assets, rejected, todos, alias, pool = draft_assets(d, outdir, bg_needed, cover_media, bg_under)
1370
2082
  media_to_asset = {a['src']['media']: a['id'] for a in assets}
1371
2083
  for m, w in (alias or {}).items():
1372
2084
  if w in media_to_asset:
1373
2085
  media_to_asset.setdefault(m, media_to_asset[w])
2086
+
2087
+ # 版式里那些贴在装饰容器上的小图(图标托底圆里的图标之类):不进包的话,消费端只看到
2088
+ # 一个空圆,只能自己编图形。它们是版式的一部分,按 icon 收进来。
2089
+ ICON_CAP = 12
2090
+ ICON_BUDGET = 3 * 1024 * 1024 # 图标是小件,占包体不该超过背景
2091
+ cW, cH = d['canvas']['px']
2092
+ icon_i, icon_bytes = 0, 0
2093
+ for a in archetypes:
2094
+ for s in a['slots']:
2095
+ m = s.get('media')
2096
+ if not m or media_to_asset.get(m) or media_to_asset.get(alias.get(m, m)):
2097
+ continue
2098
+ c = pool.get(alias.get(m, m)) or pool.get(m)
2099
+ if not c or not c.get('out') or icon_i >= ICON_CAP:
2100
+ continue
2101
+ if icon_bytes + (c.get('bytes') or 0) > ICON_BUDGET:
2102
+ continue
2103
+ if s['box'][2] > cW * 0.25 or s['box'][3] > cH * 0.25:
2104
+ continue # 不是图标,是内容配图,交给消费端自备
2105
+ icon_i += 1
2106
+ icon_bytes += c.get('bytes') or 0
2107
+ aid = 'icon-%d' % icon_i
2108
+ assets.append({'id': aid, 'kind': 'icon', 'role': None, 'src': c, 'use_full': False})
2109
+ media_to_asset[c['media']] = aid
2110
+ media_to_asset[m] = aid
2111
+ dropped_slots = []
1374
2112
  for a in archetypes:
1375
2113
  a['bg'] = media_to_asset.get(a['bg_raw'])
1376
- # 版式自带的图片元素:映射到资产 id;映射不到就不写(宁缺勿指空)
2114
+ # 版式自带的图片元素:映射到资产 id。映射不到时**保留槽位但不写 asset**——
2115
+ # 删掉整条槽,消费端看到的是一个没有图标的托底圆,和图标不进包是同一个失败模式,
2116
+ # 而且它连「这里本来有东西」都不知道。
1377
2117
  keep = []
1378
2118
  for s in a['slots']:
1379
2119
  if s.get('media'):
1380
2120
  aid = media_to_asset.get(s['media'])
1381
2121
  if not aid:
2122
+ s['role'] = 'icon'
2123
+ s.pop('media', None)
2124
+ dropped_slots.append((a['name'], s['box']))
2125
+ keep.append(s)
1382
2126
  continue
1383
2127
  s['asset'] = aid
2128
+ # role 跟着资产走:图标槽写成 logo 会让消费端把它当品牌标识,每页都摆一个
2129
+ s['role'] = next((x['kind'] for x in assets if x['id'] == aid), s['role'])
1384
2130
  keep.append(s)
1385
2131
  a['slots'] = keep
1386
2132
  roles = draft_scale(d, archetypes)
1387
2133
  slot_added = cover_slot_colors(tokens, archetypes, rows, cusage)
1388
- cands = sorted([c for c in [a['src'] for a in assets]] +
1389
- [c for c, _ in rejected], key=lambda c: (-c['n'], c['file']))
1390
- sheet = contact_sheet(outdir, cands, os.path.join(ldir, 'contact-sheet.png'))
2134
+ # 进包的资产必须全部上联系表。BRIEF L 层「看联系表确认 logo / 封面归属」,
2135
+ # 表上没有的东西它只会从表里另挑一张顶上去。封面主视觉按定义只出现在封面那一页
2136
+ # (n=1),按出现次数排序时排在最末——实测被 cands[:12] 截掉,模型于是把 bg-cover
2137
+ # 换成了已经在用的内容页背景,封面与内容页字节相同,封面主视觉整个丢失。
2138
+ decided_c = sorted([a['src'] for a in assets], key=lambda c: (-c['n'], c['file']))
2139
+ other_c = sorted([c for c, _ in rejected], key=lambda c: (-c['n'], c['file']))
2140
+ cands, seen_file = [], set()
2141
+ for c in decided_c + other_c: # 同一张图可能有多条候选记录(不同位置各一条)
2142
+ if c['file'] not in seen_file:
2143
+ seen_file.add(c['file'])
2144
+ cands.append(c)
2145
+ # 表列全部候选,拼版图只拼前几张:两者成本差着数量级。表是文字,60 行也几乎不占
2146
+ # 上下文,却是模型唯一能知道「存在这张图」的地方——名额砍在这里,被误判成未采纳的
2147
+ # 图连翻案的机会都没有。拼版图是要「看」的,60 格就是 4 列×15 行、降采样后每格
2148
+ # 糊成一团,那个上限才有意义。
2149
+ sheet_items = cands[:max(SHEET_CAP, len(decided_c))]
2150
+ sheet = contact_sheet(outdir, sheet_items, os.path.join(ldir, 'contact-sheet.png'))
1391
2151
  lsheet = layout_sheet(outdir, archetypes, os.path.join(ldir, 'layout-sheet.png'))
1392
2152
 
1393
2153
  anchors = draft_anchors(d, tokens, fonts, roles, assets, archetypes)
@@ -1395,12 +2155,42 @@ def main(argv=None):
1395
2155
  for c, why in rejected:
1396
2156
  if '近全透明' in why:
1397
2157
  gaps.append('母版/版式里的 %s 是%s,不是设计资产,任何情况下不要当背景用。' % (c['file'], why))
1398
- for f in fonts[:2]:
1399
- if len(f['stack']) > 1:
2158
+ elif '不是背景' in why:
2159
+ gaps.append('%s 在模板里铺满整页,但%s;那几页的真实背景是幻灯片自身的底色,'
2160
+ '需要时按 Colors 里的 surface 铺纯色。' % (c['file'], why))
2161
+ by_kind = {}
2162
+ for kind, kept, total, advice, where in _TRUNCATED:
2163
+ e = by_kind.setdefault(kind, {'kept': 0, 'total': 0, 'advice': advice, 'where': []})
2164
+ e['kept'] += kept
2165
+ e['total'] += total
2166
+ if where:
2167
+ e['where'].append(where)
2168
+ for kind, e in by_kind.items():
2169
+ at = ('(%s)' % '、'.join(e['where'][:6])) if e['where'] else ''
2170
+ gaps.append('%s%s按名额截断:普查到 %d 个,包内留了 %d 个%s。'
2171
+ % (kind, at, e['total'], e['kept'],
2172
+ ';' + e['advice'] if e['advice'] else ''))
2173
+ if dropped_slots:
2174
+ gaps.append('这些图标槽的源图没有随包分发(超出图标配额或不适合进包):%s。'
2175
+ '槽位保留了坐标,渲染时留空或用中性占位,不要自造图形去填。'
2176
+ % '、'.join('%s %s' % (n, b) for n, b in dropped_slots[:8]))
2177
+ # 「没命中映射表」不等于「装不上」:降级目标本身(Noto Sans SC 之类)和 Office 出厂体
2178
+ # 都不在 match 列里,但它们本来就可用。真正危险的是**既没命中、又不是已知可用字体**的
2179
+ # 那种——design.md 的字体栈里留着一个消费端装不上的商业字体名,且没有任何降级说明。
2180
+ web_ok = {norm(x) for fam in parse_fallback_table() for x in fam['fallback']}
2181
+ web_ok |= {norm(x.strip().strip('"')) for x in SYS_FALLBACK.split(',')}
2182
+ for f in fonts:
2183
+ if f.get('mapped'):
1400
2184
  gaps.append('源字体 %s 无 web 授权源,已按 font-fallback 表降级到 %s;字形细节与原稿有差异。'
1401
2185
  % (f['names'][0], f['stack'][1]))
2186
+ elif norm(f['names'][0]) in OFFICE_DEFAULT_FONTS_NORM:
2187
+ gaps.append('%s 是 Office 出厂字体,多半是模板里没清干净的残留而非设计选型;'
2188
+ '按正文/标题的实际气质挑替代体,不要照抄它。' % f['names'][0])
2189
+ elif norm(f['names'][0]) not in web_ok:
2190
+ gaps.append('源字体 %s 不在 font-fallback 表里,字体栈只有原名,消费端很可能装不上;'
2191
+ '按气质挑一个有 web 分发源的近似体补进栈,不要照抄原名。' % f['names'][0])
1402
2192
  nosize = [(a['name'], s['box']) for a in archetypes for s in a['slots']
1403
- if not s.get('asset') and not s.get('size')]
2193
+ if not s.get('asset') and not s.get('_font_size')]
1404
2194
  if nosize:
1405
2195
  gaps.append('这些文字槽在源文件任何层级都没有字号声明(都不是占位符,是普通文本框,'
1406
2196
  '继承源是 presentation.xml 的 defaultTextStyle,本抽取按约定不解继承链):'
@@ -1413,15 +2203,31 @@ def main(argv=None):
1413
2203
 
1414
2204
  emit_manifest(d, assets, ldir)
1415
2205
  emit_frontmatter(d, tokens, fonts, roles, anchors, gaps, ldir)
1416
- emit_layouts(archetypes, ldir)
2206
+ # 每张背景量一次局部对比度,作为「哪里不能压文字」的客观依据摆进判断单。
2207
+ # 只报测到的数,不替人填 avoid——哪块算主体、要不要避让,是看图才能定的。
2208
+ busy_hints = {}
2209
+ for a in assets:
2210
+ if a['kind'] != 'background' or not a['src'].get('out'):
2211
+ continue
2212
+ r = bg_busy_map(os.path.join(outdir, a['src']['out']), (cW, cH))
2213
+ if r:
2214
+ busy_hints[a['id']] = r
2215
+ facts, recipes = structure_facts(archetypes, d, all_shapes)
2216
+ for a in archetypes:
2217
+ a['flow'] = draft_flow(a, facts.get(a['name']) or {}, (cW, cH))
2218
+ emit_layouts(archetypes, ldir, busy_hints, facts, recipes)
1417
2219
  emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, ldir)
1418
2220
  emit_brief(d, (tokens, rest, fonts, roles, assets, rejected, todos, archetypes, cands, sheet,
1419
- leftover, lsheet), ldir)
2221
+ leftover, lsheet, len(sheet_items)), ldir)
1420
2222
 
1421
- print('草案就绪 -> %s' % ldir)
1422
- print(' 资产 %d(%s) 版式 %d 色 %d 字体 %d'
2223
+ # 这几行落在模型判断「skill 是不是做完了」的那一刻。只报数就会被读成「包已生成」,
2224
+ # 于是判断和打包整段被跳过,deck 拿不到任何版式坐标。所以这里报进度与下一条命令。
2225
+ print('第 1/3 步完成,判断单草案 -> %s' % ldir)
2226
+ print(' 待你确认:资产 %d(%s) 版式 %d 色 %d 字体 %d'
1423
2227
  % (len(assets), ', '.join(x['id'] for x in assets), len(archetypes), len(tokens), len(fonts)))
1424
- print(' 先读 l-out/BRIEF.md,再看 l-out/contact-sheet.png')
2228
+ print(' 2 步 读 l-out/BRIEF.md contact-sheet.png,改掉草案里的 TODO')
2229
+ print(' 第 3 步 package.py 产出 design.md + layouts.md —— deck 的版式坐标只从这两份读')
2230
+ sys.stdout.flush()
1425
2231
  return 0
1426
2232
 
1427
2233