@lark-apaas/coding-steering 0.1.18-dev.655b398 → 0.1.18-dev.7f786ca

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 (25) hide show
  1. package/package.json +1 -1
  2. package/steering/design-html/skills/charts/SKILL.md +4 -0
  3. package/steering/design-html/skills/pptx-style-extract/SKILL.md +44 -22
  4. package/steering/design-html/skills/pptx-style-extract/scripts/check_v2.py +140 -2
  5. package/steering/design-html/skills/pptx-style-extract/scripts/draft.py +1276 -177
  6. package/steering/design-html/skills/pptx-style-extract/scripts/extract.py +277 -15
  7. package/steering/design-html/skills/pptx-style-extract/scripts/ooxml.py +18 -1
  8. package/steering/design-html/skills/pptx-style-extract/scripts/package.py +392 -14
  9. package/steering/design-html/skills/pptx-style-extract/scripts/parts.py +3 -0
  10. package/steering/design-html/skills/pptx-style-extract/scripts/query.py +3 -8
  11. package/steering/design-html/skills/pptx-style-extract/scripts/test_asset_judgment_package.py +161 -0
  12. package/steering/design-html/skills/pptx-style-extract/scripts/test_background_composite.py +57 -0
  13. package/steering/design-html/skills/pptx-style-extract/scripts/test_color_contract.py +60 -0
  14. package/steering/design-html/skills/pptx-style-extract/scripts/test_design_consumer_contract.py +63 -0
  15. package/steering/design-html/skills/pptx-style-extract/scripts/test_flow_layout_contract.py +468 -0
  16. package/steering/design-html/skills/pptx-style-extract/scripts/test_layout_css.py +503 -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/scripts/test_text_role_contract.py +208 -0
  19. package/steering/design-html/skills/pptx-style-extract/v2-format-spec.md +16 -7
  20. package/steering/design-html/skills/slide-deck/SKILL.md +15 -20
  21. package/steering/design-html/skills/slide-deck/scripts/check_local_references.py +179 -0
  22. package/steering/nestjs-react-fullstack/skills/plugin-guide/SKILL.md +5 -3
  23. package/steering/nestjs-react-fullstack/skills_local/plugin-guide/SKILL.md +4 -0
  24. package/steering/vite-react/skills/plugin-guide/SKILL.md +3 -1
  25. package/steering/vite-react/skills/react-three-fiber/SKILL.md +4 -0
@@ -5,13 +5,15 @@
5
5
 
6
6
  产出 <stage1-outdir>/l-out/:
7
7
  BRIEF.md 唯一必读简报:事实 + 草案依据 + 待判断清单
8
- contact-sheet.png 候选图拼版(带编号,一次看完所有图)
8
+ contact-sheet-*.png 候选图分批拼版(带全局编号)
9
+ asset-context-sheet-*.png 候选图所在整页语境(按页去重)
9
10
  manifest.yaml / frontmatter.yaml / layouts.yaml / body.md 四件草案,可直接进 package.py
10
11
 
11
12
  草案里所有数值都来自 extract.json;凡是需要「像人一样看」才能定的,写成 `TODO:` 行
12
13
  (package.py 见 TODO 即 FAIL),由 L 层改掉。
13
14
  """
14
15
  import argparse
16
+ import copy
15
17
  import json
16
18
  import os
17
19
  import re
@@ -24,8 +26,12 @@ from ooxml import OFFICE_DEFAULT_FONTS # noqa: E402
24
26
  from census import (ASSET_WARN_SINGLE, FULLSCREEN_COVERAGE, LUM_MID, # noqa: E402
25
27
  REPEAT_MIN, SMALL_IMG_W_PCT, canvas_coverage)
26
28
 
29
+ OPAQUE_ENOUGH = 128 # 能当背景的最低不透明度:低于半透明就遮不住底下的东西,
30
+ # 那是叠加装饰不是背景
27
31
  FILL_MANY = 5 # 「被大量当填充铺开」的次数下限,用于区分卡片底与偶发用色
28
32
  BG_CONTENT_CAP = 5 # 内容页背景收几张:再多消费端也挑不过来,超出的写进 TODO 交人取舍
33
+ SHEET_BATCH = 12 # 每张联系表最多 12 个候选;候选不截断,超出就继续生成下一张
34
+ CONTEXT_BATCH = 8 # 每张整页语境表最多 8 页;同页只渲染一次
29
35
 
30
36
  HERE = os.path.dirname(os.path.abspath(__file__))
31
37
  SKILL_ROOT = os.path.dirname(HERE)
@@ -33,6 +39,22 @@ SYS_FALLBACK = '"PingFang SC", "Microsoft YaHei", sans-serif'
33
39
 
34
40
 
35
41
  # ---------------------------------------------------------------- 小工具
42
+ # 被名额截掉的东西统一记在这里,最后并进 gaps。截断本身是必要的(色板 40 个 token
43
+ # 消费端挑不过来),但**不说**就成了「悄悄少了东西而产物看起来正常」——消费端会以为
44
+ # 它拿到的就是全部。
45
+ _TRUNCATED = []
46
+
47
+
48
+ def note_truncation(kind, kept, total, advice='', where=''):
49
+ """记一条「这里按名额截断了」。kept >= total 时什么都不记。
50
+
51
+ 按 kind 归并成一条 gap:同一类截断逐处各写一行会淹掉别的 gaps。
52
+ """
53
+ if total > kept:
54
+ _TRUNCATED.append((kind, kept, total, advice, where))
55
+ return kept
56
+
57
+
36
58
  def hex2rgb(h):
37
59
  h = h.lstrip('#')
38
60
  return tuple(int(h[i:i + 2], 16) for i in (0, 2, 4))
@@ -207,7 +229,9 @@ def draft_colors(d, cusage=None):
207
229
  # 其余低饱和色一律 neutral-N——它到底是卡片底、分隔线还是描边,数据分不出来,
208
230
  # 就不要用名字去替消费方下结论;真实用法写在 Colors 表的用途列里。
209
231
  take(lambda r: r['sat'] < SAT_CUT, ['neutral', 'neutral-2', 'neutral-3'])
210
- rest = [r for r in rows if r['hex'] not in used][:6]
232
+ spare = [r for r in rows if r['hex'] not in used]
233
+ note_truncation('设计色', 6, len(spare), '色板只收主要色,其余在联系表里看')
234
+ rest = spare[:6]
211
235
  return tokens, rest, rows
212
236
 
213
237
 
@@ -243,9 +267,9 @@ OFFICE_DEFAULT_FONTS_NORM = {norm(x) for x in OFFICE_DEFAULT_FONTS}
243
267
 
244
268
 
245
269
  def cover_slot_colors(tokens, archetypes, rows, cusage):
246
- """slot 里出现的每个色值都必须在色板里有名字。
270
+ """slot CSS 里出现的每个色值都必须在色板里有名字。
247
271
 
248
- Hard Rules 写「颜色只用 colors 里的 token」,而 slot 的 color 是从模板直读的,
272
+ Hard Rules 写「颜色只用 colors 里的 token」,而 slot CSS 的 color 是从模板直读的,
249
273
  两者不对齐就等于产物自己违反自己的规则——slot 的色值直读自模板,未必都已进
250
274
  色板。这里把缺的补进色板,按用法归族命名。
251
275
  """
@@ -265,7 +289,7 @@ def cover_slot_colors(tokens, archetypes, rows, cusage):
265
289
  added = []
266
290
  for a in archetypes:
267
291
  for s in a['slots']:
268
- h = (s.get('color') or '').upper()
292
+ h = (s.get('_color') or '').upper()
269
293
  if not h.startswith('#') or h in have:
270
294
  continue
271
295
  have.add(h)
@@ -306,6 +330,7 @@ def draft_fonts(d):
306
330
  return None
307
331
 
308
332
  out = []
333
+ note_truncation('字族', 4, len(ranked), '只报渲染量最大的几族')
309
334
  for key, g in ranked[:4]:
310
335
  fam = resolve(sorted(g['names'], key=len))
311
336
  stack = [sorted(g['names'], key=len)[0]]
@@ -413,7 +438,13 @@ def usage_phrase(counter):
413
438
 
414
439
 
415
440
  def draft_anchors(d, tokens, fonts, roles, assets, archetypes):
416
- """anchors 逐条由统计覆盖率产出;证据不足就不生成这一条,不用形容词补。"""
441
+ """anchors 只报测到的数,不下「这套风格是什么」的结论。
442
+
443
+ 这一段在 design.md 里读起来像「设计总纲」,消费端会照它建全局样式。脚本写进去的
444
+ 每一句解读都会被当成规则执行——实测把 1/8 覆盖率的 logo 描述成「跨页不动」,
445
+ 消费端就建了全局 CSS 类,12 页全铺了 logo。所以这里只给覆盖率和计数,
446
+ 「这是不是这套风格的特征」由看得到图的人判断。
447
+ """
417
448
  A = []
418
449
  n_arch = len(archetypes) or 1
419
450
 
@@ -422,7 +453,8 @@ def draft_anchors(d, tokens, fonts, roles, assets, archetypes):
422
453
  chroma = [n for n in names if n.startswith(('primary', 'accent'))]
423
454
  if chroma:
424
455
  A.append((chroma[0] + '-led-palette', 'token',
425
- '表达色集中在 %s;其余 token 为底色与文字色' % '、'.join(chroma[:3])))
456
+ '有彩色 token %d 个,用量最大的是 %s'
457
+ % (len(chroma), '、'.join(chroma[:3]))))
426
458
 
427
459
  # 2. 圆角:按普查占比
428
460
  radii = d.get('radii_census') or []
@@ -432,16 +464,15 @@ def draft_anchors(d, tokens, fonts, roles, assets, archetypes):
432
464
  q0 = quant(zero['n'], tot_r)
433
465
  if q0:
434
466
  A.append(('zero-radius', 'token',
435
- '卡片、按钮、面板%s直角,圆角量为零的形状占 %d%%'
436
- % (q0, round(100.0 * zero['n'] / tot_r))))
467
+ '圆角量为零的形状占 %d%%(普查 %d 个带圆角声明的形状)'
468
+ % (round(100.0 * zero['n'] / tot_r), tot_r)))
437
469
 
438
470
  # 3. 满屏底图:按有背景的页型占比
439
471
  with_bg = sum(1 for a in archetypes if a.get('bg'))
440
472
  qb = quant(with_bg, n_arch)
441
473
  if qb:
442
474
  A.append(('full-bleed-ground', 'pattern',
443
- '页型%s由整幅铺满的底图打底(%d/%d),元素浮在图上而不是浮在纯色块上'
444
- % (qb, with_bg, n_arch)))
475
+ '%d/%d 个页型声明了整幅铺满的底图' % (with_bg, n_arch)))
445
476
 
446
477
  # 4. 标识:位置是不是真的固定,看有几个不同的 box
447
478
  logo_slots = [s for a in archetypes for s in a['slots']
@@ -450,9 +481,14 @@ def draft_anchors(d, tokens, fonts, roles, assets, archetypes):
450
481
  if any(str(s.get('asset') or '').startswith(('logo', 'slogan'))
451
482
  for s in a['slots']))
452
483
  boxes = {tuple(s['box']) for s in logo_slots}
453
- if logo_arch and len(boxes) == 1:
484
+ # anchors 是「这套风格的定义性特征」,消费端读它来建全局样式。只在少数页型出现的
485
+ # 东西写进来,等于宣布它是全局元素——实测某模板 logo 只在 1/8 个页型上,anchor 仍
486
+ # 写成「跨页不动」,消费端据此建了个全局 CSS 类,12 页全铺了 logo。
487
+ # 所以这里和其他 anchor 用同一把尺:覆盖率不过半就不进 anchors。
488
+ ql = quant(logo_arch, n_arch)
489
+ if logo_arch and len(boxes) == 1 and ql:
454
490
  A.append(('corner-locked-logo', 'component',
455
- '品牌标识在 %d/%d 个页型上出现,位置尺寸完全一致,跨页不动'
491
+ '品牌标识出现在 %d/%d 个页型上,这些页型里它的 box 完全一致'
456
492
  % (logo_arch, n_arch)))
457
493
  elif len(boxes) > 1:
458
494
  A.append(('logo-moves-by-archetype', 'component',
@@ -462,31 +498,31 @@ def draft_anchors(d, tokens, fonts, roles, assets, archetypes):
462
498
  # 5. 渐变:按普查计数
463
499
  if (d.get('geom_census') or {}).get('gradient_fills'):
464
500
  A.append(('gradient-accent', 'pattern',
465
- '强调元素用线性渐变承载,全档共 %d 处渐变填充'
466
- % (d['geom_census']['gradient_fills'])))
501
+ '全档共 %d 处渐变填充' % (d['geom_census']['gradient_fills'])))
467
502
 
468
503
  # 6. 层级:字号跨度 + 字重是否单一(字重真单一才敢说「不靠字重」)
469
504
  disp, body = roles.get('display'), roles.get('body')
470
505
  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')}
506
+ ws = {s.get('_font_weight') for a in archetypes for s in a['slots']
507
+ if s.get('_font_weight')}
472
508
  tail = (',字重只用 %s 一档' % list(ws)[0]) if len(ws) == 1 else ''
473
509
  A.append(('size-driven-hierarchy', 'pattern',
474
- '层级靠字号跨度拉开,展示档与正文档差 %.1f 倍,见 typography%s'
510
+ '最大字号档与正文档相差 %.1f 倍(见 typography)%s'
475
511
  % (disp['sz_px'] / body['sz_px'], tail)))
476
512
 
477
513
  # 7. 阴影:只在描边极少时才敢说「不用描边分隔」
478
514
  eff = d.get('effects_census') or {}
479
515
  if eff.get('outerShdw'):
480
516
  A.append(('soft-shadow-card', 'component',
481
- '容器用外阴影托起,全档 %d 处 outerShdw' % eff['outerShdw']))
517
+ '全档 %d 处 outerShdw 外阴影' % eff['outerShdw']))
482
518
 
483
519
  # 8. 双字族:只陈述分工存在,不断言「同一行混排」(普查没采集混排)
484
520
  tot_r_font = sum(f['rendered'] for f in fonts) or 1
485
521
  if len(fonts) >= 2 and fonts[1]['rendered']:
486
522
  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'])))
523
+ '用了两套字族:%s 渲染 %d 处、%s 渲染 %d 处'
524
+ % (fonts[0]['names'][0], fonts[0]['rendered'],
525
+ fonts[1]['names'][0], fonts[1]['rendered'])))
490
526
 
491
527
  # 9. 安全区:只在各页型正文左边界真的收敛时才写
492
528
  # 「多宽算正文槽」按本包自己的槽宽分布定:固定 px 门槛在窄版心模板上会一个都不剩
@@ -499,24 +535,22 @@ def draft_anchors(d, tokens, fonts, roles, assets, archetypes):
499
535
  qs = quant(common[1], len(lefts))
500
536
  if qs:
501
537
  A.append(('shared-left-margin', 'token',
502
- '正文%s对齐同一条左边界(%d/%d 个正文槽共用,坐标见 layouts)'
503
- % (qs, common[1], len(lefts))))
538
+ '%d/%d 个正文槽的左边界落在同一个 x 上(坐标见 layouts)'
539
+ % (common[1], len(lefts))))
504
540
 
505
541
  # 10. 双主题:直读事实
506
542
  themes = (d.get('theme_topology') or {}).get('themes') or []
507
543
  if len(themes) > 1:
508
544
  A.append(('dual-theme-masters', 'token',
509
- '模板带 %s 两套主题母版,同一页型有深浅两版,配色随主题整体反转'
510
- % ' / '.join(themes)))
545
+ '模板声明了 %s 两套主题母版' % ' / '.join(themes)))
511
546
 
512
547
  # 11. 画布:直读事实(兜底凑数也只用真事实)
513
548
  cv = d['canvas']['px']
514
549
  A.append(('fixed-canvas', 'token',
515
- '画布固定 %d×%d,所有坐标是这张画布上的绝对像素,不做响应式重排'
516
- % (cv[0], cv[1])))
550
+ '画布 %d×%d,layouts 里的坐标都是这张画布上的绝对像素' % (cv[0], cv[1])))
517
551
  if len(archetypes) >= 3:
518
552
  A.append(('archetype-catalog', 'pattern',
519
- '模板给出 %d 种页型,搭页从中挑,不要自创版式' % len(archetypes)))
553
+ '归纳出 %d 种页型' % len(archetypes)))
520
554
 
521
555
  seen, out = set(), []
522
556
  for a in A:
@@ -583,6 +617,101 @@ def probe_image(path):
583
617
  return info
584
618
 
585
619
 
620
+ def needs_asset_judgment(candidate):
621
+ """局部图和半透明满屏叠加层需要看图定性;不透明满屏图按背景处理。"""
622
+ effective_alpha = candidate.get('effective_alpha_mean')
623
+ if ((candidate.get('probe') or {}).get('near_blank')
624
+ or (effective_alpha is not None and effective_alpha < 13)):
625
+ return False
626
+ if not candidate.get('fullscreen'):
627
+ return True
628
+ alpha = (effective_alpha if effective_alpha is not None
629
+ else (candidate.get('probe') or {}).get('alpha_mean'))
630
+ return alpha is not None and alpha < OPAQUE_ENOUGH
631
+
632
+
633
+ def fullscreen_effective_alpha(data, outdir, shapes):
634
+ """满屏图片的实际平均 alpha,包含图片文件 alpha 与 OOXML 形状透明度。"""
635
+ media_out = {row.get('media'): row.get('out') for row in data.get('media') or []
636
+ if row.get('media') and row.get('out')}
637
+ probed = {}
638
+ effective = {}
639
+ for shape in shapes:
640
+ media = shape.get('media')
641
+ if (shape.get('kind') != 'pic' or not media
642
+ or shape.get('w_pct', 0) < 95 or shape.get('h_pct', 0) < 95):
643
+ continue
644
+ if media not in probed:
645
+ out = media_out.get(media)
646
+ probe = probe_image(os.path.join(outdir, out)) if out else {}
647
+ probed[media] = probe.get('alpha_mean')
648
+ source_alpha = probed[media]
649
+ if source_alpha is None:
650
+ source_alpha = 255.0
651
+ try:
652
+ opacity = float(shape.get('opacity', 1.0))
653
+ except (TypeError, ValueError):
654
+ opacity = 1.0
655
+ alpha = source_alpha * max(0.0, min(opacity, 1.0))
656
+ effective[media] = min(effective.get(media, 255.0), alpha)
657
+ return effective
658
+
659
+
660
+ def fullscreen_overlay_media(data, outdir, shapes):
661
+ """需要模型判断的满屏叠加层媒体。"""
662
+ return {
663
+ media for media, alpha in fullscreen_effective_alpha(data, outdir, shapes).items()
664
+ if 13 <= alpha < OPAQUE_ENOUGH
665
+ }
666
+
667
+
668
+ def bg_busy_map(path, canvas, cells=12):
669
+ """把背景图切成网格,报每格的**局部对比度**(该格内亮度极差)。
670
+
671
+ 「哪里不能压文字」的本质是「哪里花」。整幅渐变的底图各格对比度都低,说明没有
672
+ 视觉主体;有山峰、人物、产品图的底图会在主体处出现明显更高的对比度。这里只出
673
+ 客观数值和一个据此推出的草案,最终由看得到图的人定。
674
+ """
675
+ try:
676
+ from PIL import Image
677
+ except Exception:
678
+ return None
679
+ try:
680
+ im = Image.open(path).convert('L').resize((cells * 8, cells * 8))
681
+ except Exception:
682
+ return None
683
+ px = im.load()
684
+ grid = []
685
+ for gy in range(cells):
686
+ row = []
687
+ for gx in range(cells):
688
+ vals = [px[gx * 8 + x, gy * 8 + y] for y in range(8) for x in range(8)]
689
+ row.append(max(vals) - min(vals))
690
+ grid.append(row)
691
+ flat = sorted(v for row in grid for v in row)
692
+ if not flat:
693
+ return None
694
+ med = flat[len(flat) // 2]
695
+ hi = flat[int(len(flat) * 0.9)]
696
+ # 主体 = 对比度显著高于全图中位数的连片格子。阈值取「中位数与九分位的中点」,
697
+ # 由本图自己的分布定,不用固定值。
698
+ cut = (med + hi) / 2.0
699
+ cW, cH = canvas
700
+ hot = [(gx, gy) for gy in range(cells) for gx in range(cells) if grid[gy][gx] > cut]
701
+ if not hot:
702
+ return {'busy': None, 'median': med, 'p90': hi, 'why': '各处对比度一致,没有更花的区域'}
703
+ xs = [g[0] for g in hot]
704
+ ys = [g[1] for g in hot]
705
+ span = ((max(xs) - min(xs) + 1) * (max(ys) - min(ys) + 1)) / float(cells * cells)
706
+ if span > 0.5:
707
+ # 热格散落全图,外接矩形几乎覆盖整幅——圈出来等于没圈
708
+ return {'busy': None, 'median': med, 'p90': hi, 'why': '较花的格子散布全图,圈不出单一主体'}
709
+ box = [round(min(xs) * cW / cells), round(min(ys) * cH / cells),
710
+ round((max(xs) - min(xs) + 1) * cW / cells),
711
+ round((max(ys) - min(ys) + 1) * cH / cells)]
712
+ return {'busy': box, 'median': med, 'p90': hi, 'span': round(span, 2)}
713
+
714
+
586
715
  def copy_logo_candidates(outdir, logo_pool):
587
716
  if not logo_pool:
588
717
  return []
@@ -613,7 +742,8 @@ def copy_logo_candidates(outdir, logo_pool):
613
742
  return rows
614
743
 
615
744
 
616
- def draft_assets(d, outdir, bg_needed=(), cover_media=None, bg_under=None):
745
+ def draft_assets(d, outdir, bg_needed=(), cover_media=None, bg_under=None,
746
+ effective_alpha=None):
617
747
  imgs = {i['media']: i for i in d['images']}
618
748
  cluster_of = {}
619
749
  for c in d.get('media_clusters', []):
@@ -640,6 +770,7 @@ def draft_assets(d, outdir, bg_needed=(), cover_media=None, bg_under=None):
640
770
  'layer_only': bool(parts) and not slides,
641
771
  'repeat': bool(img.get('repeat_fixed')),
642
772
  'cluster': cluster_of.get(m['media']),
773
+ 'effective_alpha_mean': (effective_alpha or {}).get(m['media']),
643
774
  'probe': probe, 'reasons': m.get('reasons', []),
644
775
  })
645
776
 
@@ -666,8 +797,21 @@ def draft_assets(d, outdir, bg_needed=(), cover_media=None, bg_under=None):
666
797
  bg_i = 0
667
798
  canvas_w, canvas_h = d['canvas']['px']
668
799
  for c in kept:
669
- if c['probe'].get('near_blank'):
670
- rejected.append((c, '近全透明(alpha 均值 %.0f/255),PPT 里看不见' % c['probe']['alpha_mean']))
800
+ effective_am = c.get('effective_alpha_mean')
801
+ if (c['probe'].get('near_blank')
802
+ or (effective_am is not None and effective_am < 13)):
803
+ rejected.append((c, '近全透明(alpha 均值 %.0f/255),PPT 里看不见'
804
+ % (effective_am if effective_am is not None
805
+ else c['probe']['alpha_mean'])))
806
+ continue
807
+ # 铺满 ≠ 能当背景。背景的定义性属性是**遮盖**:它得挡住底下的东西。一张大半透明
808
+ # 的图铺满整页也遮不住任何像素,它在 PPT 里是叠在幻灯片底色上的一层装饰(顶部
809
+ # 光晕之类),底色才是真背景。实测某模板一张 alpha 均值 30/255、72% 完全透明的
810
+ # 顶部光晕被当成满屏背景收进包,消费端每页铺它,顶部就多出一条原稿没有的浓色带。
811
+ am = effective_am if effective_am is not None else c['probe'].get('alpha_mean')
812
+ if c['fullscreen'] and am is not None and am < OPAQUE_ENOUGH:
813
+ rejected.append((c, 'alpha 均值只有 %.0f/255,遮不住底下的东西——'
814
+ '它是叠在底色上的装饰层,不是背景' % am))
671
815
  continue
672
816
  if c['fullscreen']:
673
817
  if c['media'] == cover_media:
@@ -809,50 +953,161 @@ def clean_layout_name(name):
809
953
  return re.sub(r'^\d+[_\-\s]*', '', (name or '').strip()) or '未命名版式'
810
954
 
811
955
 
956
+ def is_bleed(s):
957
+ return (s.get('kind') == 'pic' and (s.get('w_pct') or 0) >= 95
958
+ and (s.get('h_pct') or 0) >= 95)
959
+
960
+
961
+ def top_bleed_media(shapes):
962
+ """一串形状里最上层的满屏图。
963
+
964
+ OOXML 的 spTree 是绘制序,靠后的画在上面。一个版式常叠两张满屏图——通用底纹在
965
+ 下、这一页的主视觉在上——所以看得见的是最后那张。取第一张会拿到底纹,实测让
966
+ 章节页的深蓝主视觉被换成了另一张鲜蓝底纹,成品与原稿完全不是一个颜色。
967
+ """
968
+ out = None
969
+ for s in shapes:
970
+ if is_bleed(s) and s.get('media'):
971
+ out = s['media']
972
+ return out
973
+
974
+
975
+ def slot_overlaps(slots):
976
+ """同一页型里坐标互相重叠的槽对。只报事实,不改坐标——坐标是从模板量的。"""
977
+ out = []
978
+ for i in range(len(slots)):
979
+ for j in range(i + 1, len(slots)):
980
+ a, b = slots[i].get('box'), slots[j].get('box')
981
+ if not (a and b):
982
+ continue
983
+ ox = min(a[0] + a[2], b[0] + b[2]) - max(a[0], b[0])
984
+ oy = min(a[1] + a[3], b[1] + b[3]) - max(a[1], b[1])
985
+ if ox > 0 and oy > 0:
986
+ out.append('%s×%s 叠 %dx%d' % (slots[i].get('role'), slots[j].get('role'),
987
+ round(ox), round(oy)))
988
+ return out
989
+
990
+
991
+ def css_number(value, digits=3):
992
+ """CSS 数值稳定格式:整数不带小数,其余去掉无意义尾零。"""
993
+ number = round(float(value), digits)
994
+ if number == int(number):
995
+ return str(int(number))
996
+ return ('%.*f' % (digits, number)).rstrip('0').rstrip('.')
997
+
998
+
812
999
  def slot_style(s):
813
- """占位符自带的排版样式——字号/色值/对齐/字重都是直读,不给消费端留编的空间。
1000
+ """占位符自带的排版样式,统一转成可直接写进 HTML style 的 CSS 声明串。
814
1001
 
815
1002
  样式可能在三层:lstStyle.lvl1pPr(版式占位符常用)、段落 defRPr(Mac Office
816
1003
  导出把大量属性写在这一层)、段落 pPr(对齐)。逐层兜底,缺一层就往下取。
1004
+
1005
+ `box` 是布局几何,继续由 slot 独立承载;其余渲染属性不再泄漏成 size / color /
1006
+ align / insets_px 等 PPTX 中间字段。下划线开头的键仅供 draft 内部统计,emit_layouts
1007
+ 不会写进消费者产物。
817
1008
  """
818
1009
  txt = s.get('text') or {}
819
- ls = dict((txt.get('lstStyle') or {}).get('lvl1pPr') or {})
1010
+ inherited = dict((txt.get('lstStyle') or {}).get('lvl1pPr') or {})
1011
+ ls = {}
820
1012
  # 四层逐级兜底,按 OOXML 的就近原则:run rPr → 段落 defRPr → 段落 pPr → lstStyle。
821
1013
  # 只枚举前几层会整份漏掉——有的导出器把字号全写在 run rPr 上,lstStyle 一个都没有。
822
1014
  for para in (txt.get('paragraphs') or []):
823
- srcs = [r.get('rPr') or {} for r in (para.get('runs') or [])]
1015
+ srcs = [r for r in (para.get('runs') or [])]
824
1016
  srcs.append(para.get('defRPr') or {})
825
1017
  srcs.append({k: v for k, v in para.items() if k not in ('runs', 'defRPr')})
826
1018
  for src in srcs:
827
1019
  for k, v in (src or {}).items():
828
1020
  if v is not None:
829
1021
  ls.setdefault(k, v)
830
- if ls.get('sz_px'):
831
- break
1022
+ for k, v in inherited.items():
1023
+ if v is not None:
1024
+ ls.setdefault(k, v)
832
1025
  if not ls.get('sz_px'):
833
1026
  # 仍无声明:退到整形状里出现过的最大字号(generic walk),仍是文件里的值
834
1027
  anysz = shape_sz(s)
835
1028
  if anysz:
836
1029
  ls['sz_px'] = anysz
1030
+ body = txt.get('bodyPr') or {}
1031
+ css = []
837
1032
  out = {}
1033
+ insets = body.get('insets_px') or {}
1034
+ if insets:
1035
+ css.append('box-sizing: border-box')
1036
+ css.append('padding: %spx %spx %spx %spx' % (
1037
+ css_number(insets.get('tIns', 0) or 0),
1038
+ css_number(insets.get('rIns', 0) or 0),
1039
+ css_number(insets.get('bIns', 0) or 0),
1040
+ css_number(insets.get('lIns', 0) or 0),
1041
+ ))
838
1042
  if ls.get('sz_px'):
839
- out['size'] = round(ls['sz_px'])
1043
+ # normAutofit 的 fontScale 是模板让大字装进小框的手段——不乘它,消费端拿到的是
1044
+ # 未缩放字号,字比框高,渐变裁切会把溢出的底部切成透明。缺省 1.0(无 autofit / 无缩放)。
1045
+ scale = body.get('font_scale')
1046
+ raw = ls['sz_px'] * scale if scale else ls['sz_px']
1047
+ size = round(raw)
1048
+ css.append('font-size: %dpx' % size)
1049
+ out['_font_size'] = size
1050
+ typeface = ls.get('ea') or ls.get('latin') or ls.get('cs')
1051
+ if typeface:
1052
+ css.append('font-family: %s' % font_css([typeface]))
1053
+ weight = ls.get('weight') or (700 if ls.get('bold') else None)
1054
+ if weight:
1055
+ css.append('font-weight: %s' % weight)
1056
+ out['_font_weight'] = weight
1057
+ if ls.get('italic'):
1058
+ css.append('font-style: italic')
1059
+ decorations = []
1060
+ if ls.get('underline'):
1061
+ decorations.append('underline')
1062
+ if ls.get('strike'):
1063
+ decorations.append('line-through')
1064
+ if decorations:
1065
+ css.append('text-decoration: %s' % ' '.join(decorations))
1066
+ if ls.get('spc_px') is not None:
1067
+ css.append('letter-spacing: %spx' % css_number(ls['spc_px']))
840
1068
  col = (ls.get('color') or {}).get('resolved')
841
1069
  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')
1070
+ css.append('color: %s' % col)
1071
+ out['_color'] = col
1072
+ else:
1073
+ # 占位符的字色也可以是 gradFill(章节页的大号序号常这么做)。解析层已经把
1074
+ # stops 和角度记全了,这里只取单色就会整条丢掉,消费端只能自己编一个平色。
1075
+ # decor 同一约定:css 是可直接写进 style 的声明串。
1076
+ f = ls.get('fill') or {}
1077
+ if f.get('type') == 'gradient':
1078
+ g = _load_query()._css_gradient(f)
1079
+ if g:
1080
+ css += ['background-image: %s' % g, '-webkit-background-clip: text',
1081
+ 'background-clip: text', 'color: transparent']
1082
+ align = ls.get('algn')
1083
+ if align:
1084
+ css.append('text-align: %s' % {
1085
+ 'l': 'left', 'ctr': 'center', 'r': 'right', 'just': 'justify',
1086
+ }.get(align, align))
1087
+ line_spacing = ls.get('lnSpc') or {}
1088
+ # normAutofit 的 lnSpcReduction 与 fontScale 同时把行距压缩,一起缩才装得进原框。
1089
+ reduction = body.get('ln_spc_reduction') or 0
1090
+ if line_spacing.get('mult'):
1091
+ mult = line_spacing['mult'] * 1.2 * (1 - reduction)
1092
+ css.append('line-height: %s' % css_number(mult))
1093
+ elif line_spacing.get('px'):
1094
+ css.append('line-height: %spx' % css_number(line_spacing['px'] * (1 - reduction)))
1095
+ anchor = body.get('anchor')
850
1096
  if anchor in ('ctr', 'b'):
851
- out['valign'] = {'ctr': 'middle', 'b': 'bottom'}[anchor]
1097
+ css += ['display: flex', 'flex-direction: column',
1098
+ 'justify-content: %s' % {'ctr': 'center', 'b': 'flex-end'}[anchor]]
1099
+ if body.get('rot'):
1100
+ try:
1101
+ degrees = float(body['rot']) / 60000.0
1102
+ css.append('rotate: %sdeg' % css_number(degrees))
1103
+ except (TypeError, ValueError):
1104
+ pass
1105
+ if css:
1106
+ out['css'] = '; '.join(css)
852
1107
  return out
853
1108
 
854
1109
 
855
- def instance_override(shapes, slide_part, slots, bgm, cW, cH):
1110
+ def instance_override(shapes, slide_part, slots, bgm, cW, cH, composites=None):
856
1111
  """实例页覆盖版式:版式是骨架,实例页才是设计师最终摆定的样子。
857
1112
 
858
1113
  版式底图常是多个版式共用的通用底纹,实例页可能另铺主视觉大图;标题占位符的框高
@@ -862,11 +1117,7 @@ def instance_override(shapes, slide_part, slots, bgm, cW, cH):
862
1117
  ins = [s for s in shapes if s.get('part') == slide_part]
863
1118
  if not ins:
864
1119
  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
1120
+ bgm = (composites or {}).get(slide_part) or top_bleed_media(ins) or bgm
870
1121
  texts = []
871
1122
  for s in ins:
872
1123
  b = s.get('box') or {}
@@ -894,13 +1145,14 @@ def layouts_from_template(d, shapes, cW, cH):
894
1145
  """
895
1146
  by_part = defaultdict(list)
896
1147
  for s in shapes:
897
- if s.get('layer') == 'layout' and s.get('ph'):
1148
+ if (s.get('layer') == 'layout' and s.get('kind') == 'sp'
1149
+ and (s.get('box') or {}).get('w') and (s.get('ph') or shape_text(s))):
898
1150
  by_part[s['part']].append(s)
899
1151
  bg_of_layout = {}
1152
+ composites = d.get('background_composites') or {}
900
1153
  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'))
1154
+ if s.get('layer') == 'layout' and is_bleed(s) and s.get('media'):
1155
+ bg_of_layout[s['part']] = s['media'] # 靠后者在上层,最后一张才是看得见的
904
1156
  topo = d.get('theme_topology') or {}
905
1157
  theme_of_master = {m['master']: m.get('theme_label')
906
1158
  for m in (topo.get('per_master') or [])}
@@ -922,11 +1174,11 @@ def layouts_from_template(d, shapes, cW, cH):
922
1174
  phs.sort(key=lambda s: ((s['box'].get('y') or 0), (s['box'].get('x') or 0)))
923
1175
  slots, seen_kind = [], set()
924
1176
  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,不是内容槽
1177
+ t = PH_TO_TYPE.get((s.get('ph') or {}).get('type'), 'body')
1178
+ if t in ('slide-number', 'footer') and not shape_text(s):
1179
+ continue # chrome 占位符不是实际元素
928
1180
  b = s['box']
929
- role = t if t in ('title', 'subtitle') else 'body'
1181
+ role = t if t in ('title', 'subtitle', 'footer', 'slide-number') else 'body'
930
1182
  if t == 'title' and 'title' in seen_kind:
931
1183
  role, t = 'subtitle', 'subtitle'
932
1184
  seen_kind.add(t)
@@ -935,10 +1187,18 @@ def layouts_from_template(d, shapes, cW, cH):
935
1187
  round(b.get('w', 0)), round(b.get('h', 0))],
936
1188
  'txt': shape_text(s) or (s.get('name') or '')[:24]}
937
1189
  row.update(slot_style(s))
1190
+ if t == 'body':
1191
+ ph = s.get('ph') or {}
1192
+ row.update({
1193
+ '_needs_role': True,
1194
+ '_source_layer': 'layout',
1195
+ '_placeholder': '%s/%s' % (
1196
+ ph.get('type') or '-', ph.get('idx') or '-'),
1197
+ })
938
1198
  slots.append(row)
939
1199
  # 非满屏的图片元素(logo / 联名标 / 装饰)——它们逐版式换位置换尺寸,
940
1200
  # 必须按版式落进 slots,压成一条全局「固定位」规则就会撞标题。
941
- bgm = bg_of_layout.get(l['part'])
1201
+ bgm = composites.get(l['part']) or bg_of_layout.get(l['part'])
942
1202
  for s in shapes:
943
1203
  if s['part'] != l['part'] or s.get('kind') != 'pic' or not s.get('media'):
944
1204
  continue
@@ -955,7 +1215,8 @@ def layouts_from_template(d, shapes, cW, cH):
955
1215
  continue
956
1216
  inst = slide_of_layout.get(l['part'])
957
1217
  if inst:
958
- slots, bgm = instance_override(shapes, inst, slots, bgm, cW, cH)
1218
+ slots, bgm = instance_override(
1219
+ shapes, inst, slots, bgm, cW, cH, composites)
959
1220
  taken = {tuple(s['box']) for s in slots}
960
1221
  decor = collect_decor(shapes, inst or l['part'], taken, (cW, cH))
961
1222
  named_role = role_of_name(l.get('name'))
@@ -993,6 +1254,7 @@ def layouts_from_template(d, shapes, cW, cH):
993
1254
  # 版式名认不出 role 时不装作有把握:置信度降到 low,让 L 层看图定
994
1255
  'pic_n': 0, 'confidence': 'low' if r.get('role_guessed') else 'high',
995
1256
  'theme': r['theme'] if multi else None,
1257
+ '_layout_part': r['part'],
996
1258
  'source': 'layout:' + r['part'].split('/')[-1]})
997
1259
  return arch
998
1260
 
@@ -1048,20 +1310,191 @@ def collect_decor(shapes, part, taken_boxes, canvas, limit=10):
1048
1310
  # 按面积降序取前 limit 条:撑起版式的结构性形状总在最前,零星噪点自然落在截断线外,
1049
1311
  # 不需要再设一个「多小算噪点」的尺寸门槛(那种门槛会误杀 1px 分隔线)。
1050
1312
  out.sort(key=lambda d: -d['area'])
1313
+ note_truncation('装饰形状', limit, len(out), '按面积降序保留,剩下的多是零星小件',
1314
+ part.split('/')[-1])
1051
1315
  return out[:limit] # 同款不同位置都要留,位置本身是版式信息
1052
1316
 
1053
1317
 
1054
- def draft_layouts(d, outdir):
1055
- shapes = json.load(open(os.path.join(outdir, 'ref', 'shapes.json'), encoding='utf-8'))['shapes']
1318
+ def placeholder_key(shape):
1319
+ ph = shape.get('ph') or {}
1320
+ if not ph:
1321
+ return None
1322
+ return (ph.get('type') or 'body', str(ph.get('idx') or ''))
1323
+
1324
+
1325
+ def merge_dict(base, override):
1326
+ """把实例页的非空声明叠到版式声明上;空实例占位符继续继承版式事实。"""
1327
+ out = copy.deepcopy(base or {})
1328
+ for key, value in (override or {}).items():
1329
+ if value is None or value == []:
1330
+ continue
1331
+ if isinstance(value, dict) and isinstance(out.get(key), dict):
1332
+ out[key] = merge_dict(out[key], value)
1333
+ else:
1334
+ out[key] = copy.deepcopy(value)
1335
+ return out
1336
+
1337
+
1338
+ def inherited_text_shapes(layout_shapes, slide_shapes):
1339
+ """返回实例页可用的文字形状,并补齐其引用版式中的占位符几何与样式。"""
1340
+ layout_text = []
1341
+ for shape in layout_shapes:
1342
+ if shape.get('kind') != 'sp' or not (shape.get('box') or {}).get('w'):
1343
+ continue
1344
+ ph = shape.get('ph') or {}
1345
+ ph_type = ph.get('type')
1346
+ if shape_text(shape) or (ph and ph_type not in ('ftr', 'dt', 'sldNum')):
1347
+ layout_text.append(shape)
1348
+ by_placeholder = {placeholder_key(s): s for s in layout_text if placeholder_key(s)}
1349
+ used = set()
1350
+ out = []
1351
+ for shape in slide_shapes:
1352
+ if shape.get('kind') != 'sp':
1353
+ continue
1354
+ key = placeholder_key(shape)
1355
+ base = by_placeholder.get(key)
1356
+ if base:
1357
+ merged = merge_dict(base, shape)
1358
+ merged['text'] = merge_dict(base.get('text'), shape.get('text'))
1359
+ if not shape_text(shape):
1360
+ merged['text']['paragraphs'] = copy.deepcopy(
1361
+ (base.get('text') or {}).get('paragraphs') or [])
1362
+ used.add(key)
1363
+ out.append((merged, 'slide+layout'))
1364
+ elif (shape.get('box') or {}).get('w') and shape_text(shape):
1365
+ out.append((shape, 'slide'))
1366
+ for shape in layout_text:
1367
+ key = placeholder_key(shape)
1368
+ if key not in used:
1369
+ out.append((shape, 'layout'))
1370
+ return out
1371
+
1372
+
1373
+ def slide_image_marks(data, included_fullscreen=()):
1374
+ """从图片普查补齐形状图片填充;它们没有独立 pic 节点,但仍有媒体与坐标。"""
1375
+ allowed_fullscreen = set(included_fullscreen)
1376
+ out = defaultdict(list)
1377
+ for image in data.get('images') or []:
1378
+ media = image.get('media')
1379
+ if not media or (image.get('fullscreen') and media not in allowed_fullscreen):
1380
+ continue
1381
+ for cluster in image.get('boxes') or []:
1382
+ box = cluster.get('box')
1383
+ if not box or not box.get('w'):
1384
+ continue
1385
+ for part in cluster.get('parts') or []:
1386
+ if '/slides/' not in part and '/slideLayouts/' not in part:
1387
+ continue
1388
+ out[part].append({'media': media, 'box': box})
1389
+ return out
1390
+
1391
+
1392
+ def has_small_image_cluster(pages, canvas):
1393
+ """多张独立小图需要保留整页语境,供模型判断 logo 墙或内容图组。"""
1394
+ canvas_w, canvas_h = canvas
1395
+ for page in pages:
1396
+ media = {
1397
+ mark.get('media')
1398
+ for mark in page.get('marks') or []
1399
+ if mark.get('media')
1400
+ and (mark.get('box') or {}).get('w', canvas_w) <= canvas_w * 0.25
1401
+ and (mark.get('box') or {}).get('h', canvas_h) <= canvas_h * 0.25
1402
+ }
1403
+ if len(media) >= 3:
1404
+ return True
1405
+ return False
1406
+
1407
+
1408
+ def add_template_image_marks(archetypes, data, included_fullscreen=()):
1409
+ """把版式和实例页的图片填充补进 form=3 页型。"""
1410
+ marks_by_part = slide_image_marks(data, included_fullscreen)
1411
+ layout_of_slide = (data.get('reference_graph') or {}).get('layout_of_slide') or {}
1412
+ by_layout = {archetype.get('_layout_part'): archetype for archetype in archetypes}
1413
+ for part, marks in marks_by_part.items():
1414
+ layout_part = layout_of_slide.get(part, part)
1415
+ archetype = by_layout.get(layout_part)
1416
+ if not archetype:
1417
+ continue
1418
+ seen = {
1419
+ (slot.get('media'), tuple(slot.get('box') or ()))
1420
+ for slot in archetype.get('slots') or []
1421
+ if slot.get('media')
1422
+ }
1423
+ for mark in marks:
1424
+ box = mark['box']
1425
+ rounded = [round(box.get(key, 0)) for key in ('x', 'y', 'w', 'h')]
1426
+ key = (mark['media'], tuple(rounded))
1427
+ if key in seen:
1428
+ continue
1429
+ seen.add(key)
1430
+ archetype['slots'].append({
1431
+ 'role': 'logo',
1432
+ 'type': 'pic',
1433
+ 'sz': 0,
1434
+ 'txt': '',
1435
+ 'media': mark['media'],
1436
+ 'box': rounded,
1437
+ })
1438
+
1439
+
1440
+ def attach_leftover_image_marks(archetypes, pages, kept_parts):
1441
+ """把孤例图片槽并入最接近的真实页型,不为图片单独制造伪页型。"""
1442
+ if not archetypes:
1443
+ return
1444
+ for page in pages:
1445
+ if page['part'] in kept_parts or not page.get('marks'):
1446
+ continue
1447
+ page_bg = page.get('rendered_bg') or page.get('bg_media') or page.get('bg_color')
1448
+ target = min(archetypes, key=lambda archetype: (
1449
+ 0 if page.get('layout') in (archetype.get('_source_layouts') or ()) else 1,
1450
+ 0 if page_bg in (archetype.get('_source_backgrounds') or ()) else 1,
1451
+ abs(len(page.get('texts') or []) - archetype.get('_text_n', 0)),
1452
+ abs(page['no'] - archetype.get('rep', page['no'])),
1453
+ ))
1454
+ seen = {
1455
+ (slot.get('media'), tuple(slot.get('box') or ()))
1456
+ for slot in target.get('slots') or []
1457
+ if slot.get('media')
1458
+ }
1459
+ for mark in page['marks']:
1460
+ box = [round(mark['box'].get(key, 0)) for key in ('x', 'y', 'w', 'h')]
1461
+ key = (mark['media'], tuple(box))
1462
+ if key in seen:
1463
+ continue
1464
+ seen.add(key)
1465
+ target['slots'].append({
1466
+ 'role': 'logo',
1467
+ 'type': 'pic',
1468
+ 'sz': 0,
1469
+ 'txt': '',
1470
+ 'media': mark['media'],
1471
+ 'box': box,
1472
+ })
1473
+
1474
+
1475
+ def draft_layouts(d, outdir, effective_alpha=None):
1476
+ with open(os.path.join(outdir, 'ref', 'shapes.json'), encoding='utf-8') as stream:
1477
+ shapes = json.load(stream)['shapes']
1056
1478
  cW, cH = d['canvas']['px']
1479
+ if effective_alpha is None:
1480
+ effective_alpha = fullscreen_effective_alpha(d, outdir, shapes)
1481
+ overlay_media = {
1482
+ media for media, alpha in effective_alpha.items()
1483
+ if 13 <= alpha < OPAQUE_ENOUGH
1484
+ }
1057
1485
  if (d.get('form_hint') or {}).get('form') == 3:
1058
1486
  arch = layouts_from_template(d, shapes, cW, cH)
1059
1487
  if len(arch) >= 3:
1488
+ add_template_image_marks(arch, d, overlay_media)
1060
1489
  return arch, [], []
1061
1490
  by_slide = defaultdict(list)
1491
+ by_layout = defaultdict(list)
1062
1492
  for s in shapes:
1063
1493
  if s.get('layer') == 'slide':
1064
1494
  by_slide[s['part']].append(s)
1495
+ elif s.get('layer') == 'layout':
1496
+ by_layout[s['part']].append(s)
1497
+ image_marks = slide_image_marks(d, overlay_media)
1065
1498
 
1066
1499
  bg_of_slide, layout_of_slide = {}, {}
1067
1500
  for s in d.get('slides', []):
@@ -1069,40 +1502,66 @@ def draft_layouts(d, outdir):
1069
1502
  bg_of_slide[s['part']] = json.dumps(bg, sort_keys=True) if isinstance(bg, dict) else bg
1070
1503
  layout_of_slide[s['part']] = s.get('layout')
1071
1504
  # 版式层的满屏底图(form=2 常态:底图挂在 layout 上)
1505
+ composites = d.get('background_composites') or {}
1072
1506
  bg_of_layout = {}
1073
1507
  for s in shapes:
1074
- if (s.get('layer') == 'layout' and s.get('kind') == 'pic'
1075
- and s.get('w_pct', 0) >= 95 and s.get('h_pct', 0) >= 95):
1076
- bg_of_layout.setdefault(s['part'], s.get('media'))
1508
+ if s.get('layer') == 'layout' and is_bleed(s) and s.get('media'):
1509
+ bg_of_layout[s['part']] = s['media']
1077
1510
 
1078
1511
  pages = []
1079
1512
  for part, sh in sorted(by_slide.items(), key=lambda kv: slide_no(kv[0])):
1080
- bg_media = None
1081
- for s in sh:
1082
- if s.get('kind') == 'pic' and s.get('w_pct', 0) >= 95 and s.get('h_pct', 0) >= 95:
1083
- bg_media = s.get('media')
1084
- break
1513
+ layout_part = layout_of_slide.get(part)
1514
+ layout_shapes = by_layout.get(layout_part) or []
1515
+ bg_media = top_bleed_media(sh)
1085
1516
  if bg_media is None:
1086
- bg_media = bg_of_layout.get(layout_of_slide.get(part))
1517
+ bg_media = bg_of_layout.get(layout_part)
1518
+ rendered_bg = (composites.get(part)
1519
+ or composites.get(layout_part)
1520
+ or bg_media)
1087
1521
  texts = []
1088
- for s in sh:
1089
- if s.get('kind') != 'sp':
1090
- continue
1091
- txt = shape_text(s)
1092
- if not txt:
1093
- continue
1522
+ for s, source_layer in inherited_text_shapes(layout_shapes, sh):
1523
+ txt = shape_text(s) or (s.get('name') or '')[:24]
1094
1524
  b = s.get('box') or {}
1095
1525
  if b.get('w', 0) < DECOR_MIN or b.get('h', 0) < 16:
1096
1526
  continue
1097
- texts.append({'sz': shape_sz(s), 'box': b, 'txt': txt, 'style': slot_style(s)})
1527
+ ph = s.get('ph') or {}
1528
+ ph_type = ph.get('type')
1529
+ direct_type = PH_TO_TYPE.get(ph_type, 'body')
1530
+ texts.append({
1531
+ 'sz': shape_sz(s),
1532
+ 'box': b,
1533
+ 'txt': txt,
1534
+ 'style': slot_style(s),
1535
+ 'direct_type': direct_type,
1536
+ 'needs_role': direct_type == 'body',
1537
+ 'source_layer': source_layer,
1538
+ 'placeholder': '%s/%s' % (ph_type or '-', ph.get('idx') or '-'),
1539
+ })
1098
1540
  texts.sort(key=lambda t: (-t['sz'], t['box'].get('y', 0)))
1099
- pics = [s for s in sh if s.get('kind') == 'pic' and s.get('w_pct', 0) < 95]
1541
+ visible_shapes = layout_shapes + sh
1542
+ pics = []
1543
+ for shape in visible_shapes:
1544
+ if shape.get('kind') != 'pic':
1545
+ continue
1546
+ if shape.get('w_pct', 0) < 95 or shape.get('media') in overlay_media:
1547
+ pics.append(shape)
1100
1548
  # 小图元素(logo / 角标 / 装饰)逐页记位置,供 archetype 落 slots
1101
1549
  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]
1550
+ if s.get('media') and (s.get('box') or {}).get('w')]
1551
+ seen_marks = {
1552
+ (mark['media'], round(mark['box'].get('x', 0)), round(mark['box'].get('y', 0)))
1553
+ for mark in marks
1554
+ }
1555
+ for mark in image_marks.get(part) or []:
1556
+ key = (mark['media'], round(mark['box'].get('x', 0)),
1557
+ round(mark['box'].get('y', 0)))
1558
+ if key not in seen_marks:
1559
+ seen_marks.add(key)
1560
+ marks.append(mark)
1103
1561
  pages.append({'part': part, 'no': slide_no(part), 'bg_media': bg_media,
1562
+ 'rendered_bg': rendered_bg,
1104
1563
  'bg_color': bg_of_slide.get(part), 'texts': texts, 'pic_n': len(pics),
1105
- 'marks': marks, 'shape_n': len(sh)})
1564
+ 'marks': marks, 'shape_n': len(visible_shapes), 'layout': layout_part})
1106
1565
 
1107
1566
  # 页型的**角色**(封面 / 章节页 / 内容页……)不在这里判:那是看图才能下的结论,
1108
1567
  # 交给读得到重建图的模型。脚本只做客观归并——同一张底图 + 文字块数量相近的页
@@ -1133,13 +1592,22 @@ def draft_layouts(d, outdir):
1133
1592
  break
1134
1593
  if g not in kept:
1135
1594
  kept.append(g)
1595
+ # logo 墙必须保留整页结构,模型才能结合文本与多图关系判断。其他带图孤例不提升
1596
+ # 成完整页型,稍后把图片槽并入最接近的真实页型。
1597
+ for group in ranked:
1598
+ if group not in kept and has_small_image_cluster(group[1], (cW, cH)):
1599
+ kept.append(group)
1136
1600
  leftover = sorted(p['no'] for g in ranked if g not in kept for p in g[1])
1601
+ kept_pages = {page['part'] for _, group_pages in kept for page in group_pages}
1137
1602
 
1138
1603
  archetypes = []
1139
1604
  for gi, ((bg_raw, _band), ps) in enumerate(kept, 1):
1140
1605
  rep = max(ps, key=lambda p: len(p['texts']))
1141
1606
  if bg_raw == '__first__':
1142
1607
  bg_raw = rep['bg_media'] or rep['bg_color'] or 'none'
1608
+ rendered_bg = rep.get('rendered_bg')
1609
+ if rendered_bg:
1610
+ bg_raw = rendered_bg
1143
1611
  name = 'layout-%d' % gi
1144
1612
  # 标题按「位置 + 跨度」认,不按字号——big-number 类的巨号数值常比标题还大
1145
1613
  # 标题 = 该页最靠上的那批文本里最宽的一块。不按「画布前 28%」这类固定比例切:
@@ -1153,9 +1621,13 @@ def draft_layouts(d, outdir):
1153
1621
  rest.sort(key=lambda t: (t['box'].get('y', 0), t['box'].get('x', 0)))
1154
1622
  ordered = ([title] if title else []) + rest
1155
1623
  slots = []
1156
- for i, t in enumerate(ordered[:6]):
1624
+ for i, t in enumerate(ordered):
1157
1625
  b = t['box']
1158
- if t is title:
1626
+ if t.get('needs_role'):
1627
+ role = typ = 'body'
1628
+ elif t.get('direct_type') in ('title', 'subtitle', 'footer', 'slide-number'):
1629
+ role = typ = t['direct_type']
1630
+ elif t is title:
1159
1631
  role = typ = 'title'
1160
1632
  elif (title and i == 1
1161
1633
  # 副标题 = 紧跟在标题下方、与标题左对齐的那一块。三个量都相对标题
@@ -1171,26 +1643,55 @@ def draft_layouts(d, outdir):
1171
1643
  round(b.get('w', 0)), round(b.get('h', 0))],
1172
1644
  'type': typ, 'sz': t['sz'], 'txt': t['txt']}
1173
1645
  row.update(t.get('style') or {})
1646
+ if t.get('needs_role'):
1647
+ row.update({
1648
+ '_needs_role': True,
1649
+ '_source_layer': t.get('source_layer'),
1650
+ '_placeholder': t.get('placeholder'),
1651
+ })
1174
1652
  slots.append(row)
1175
- # 代表页上的小图元素按位置去重后落 slots(同一 logo 在不同页型位置不同)
1653
+ # 同组页面上的图片元素按素材+位置去重后落候选 slots。内容图去掉具体资产引用,
1654
+ # 保留通用图片槽;装饰图绑定资产,避免非代表页上的装饰没有进入 layouts。
1176
1655
  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))
1656
+ for page in ps:
1657
+ for mk in page.get('marks') or []:
1658
+ b = mk['box']
1659
+ key = (mk['media'], round(b.get('x', 0)), round(b.get('y', 0)))
1660
+ if key in seen_mark:
1661
+ continue
1662
+ seen_mark.add(key)
1663
+ slots.append({'role': 'logo', 'type': 'pic', 'sz': 0, 'txt': '',
1664
+ 'media': mk['media'],
1665
+ 'box': [round(b.get('x', 0)), round(b.get('y', 0)),
1666
+ round(b.get('w', 0)), round(b.get('h', 0))]})
1667
+ taken = {tuple(s['box']) for s in slots}
1668
+ decor = []
1669
+ seen_decor = set()
1670
+ for source_part in (rep.get('layout'), rep['part']):
1671
+ for item in collect_decor(shapes, source_part, taken, (cW, cH)):
1672
+ key = (tuple(item['box']), item['geom'], item['css'])
1673
+ if key not in seen_decor:
1674
+ seen_decor.add(key)
1675
+ decor.append(item)
1188
1676
  archetypes.append({'name': name, 'bg': None, 'bg_raw': bg_raw, 'slots': slots,
1189
1677
  'decor': decor,
1190
1678
  'pages': sorted(p['no'] for p in ps), 'rep': rep['no'],
1191
1679
  'pic_n': rep['pic_n'],
1680
+ '_source_layouts': sorted({
1681
+ p['layout'] for p in ps if p.get('layout')
1682
+ }),
1683
+ '_source_backgrounds': sorted({
1684
+ p.get('rendered_bg') or p.get('bg_media') or p.get('bg_color')
1685
+ for p in ps
1686
+ if p.get('rendered_bg') or p.get('bg_media') or p.get('bg_color')
1687
+ }),
1688
+ '_text_n': len(rep['texts']),
1192
1689
  'confidence': 'high' if len(ps) >= 3 else
1193
1690
  ('medium' if len(ps) == 2 else 'low')})
1691
+ # 普通孤例的图片候选仍需 layouts 槽位闭环,但不值得把整页文本升级成正式页型:
1692
+ # 那会为每个孤例增加名称、角色、文本角色和布局模式判断。优先按同源版式承载,
1693
+ # 再按背景、文本密度和相邻页匹配到最接近的真实页型。
1694
+ attach_leftover_image_marks(archetypes, pages, kept_pages)
1194
1695
  return archetypes, pages, leftover
1195
1696
 
1196
1697
 
@@ -1202,13 +1703,19 @@ def layout_sheet(outdir, archetypes, path):
1202
1703
  reps = [x for x in reps if x is not None]
1203
1704
  if not reps:
1204
1705
  return None
1205
- import subprocess
1206
- r = subprocess.run([sys.executable, os.path.join(HERE, 'render_pages.py'), outdir,
1207
- '--pages', 'layouts' if use_layout else 'slides',
1208
- '--only', ','.join(map(str, reps)), '--no-html'],
1209
- capture_output=True, text=True)
1210
1706
  png_dir = os.path.join(outdir, 'ref', 'rebuild', 'png')
1211
- if r.returncode or not os.path.isdir(png_dir):
1707
+ kind = 'layout' if use_layout else 'slide'
1708
+ missing = [no for no in reps
1709
+ if not os.path.exists(os.path.join(png_dir, '%s-%s.png' % (kind, no)))]
1710
+ if missing:
1711
+ import subprocess
1712
+ r = subprocess.run([sys.executable, os.path.join(HERE, 'render_pages.py'), outdir,
1713
+ '--pages', 'layouts' if use_layout else 'slides',
1714
+ '--only', ','.join(map(str, missing)), '--no-html'],
1715
+ capture_output=True, text=True)
1716
+ if r.returncode:
1717
+ return None
1718
+ if not os.path.isdir(png_dir):
1212
1719
  return None
1213
1720
  try:
1214
1721
  from PIL import Image, ImageDraw
@@ -1224,7 +1731,7 @@ def layout_sheet(outdir, archetypes, path):
1224
1731
  x = pad + (i % cols) * (cw + pad)
1225
1732
  y = pad + (i // cols) * (ch + pad + lab)
1226
1733
  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))
1734
+ f = os.path.join(png_dir, '%s-%s.png' % (kind, no))
1228
1735
  if os.path.exists(f):
1229
1736
  im = Image.open(f).convert('RGB')
1230
1737
  im.thumbnail((cw, ch))
@@ -1241,13 +1748,13 @@ def layout_sheet(outdir, archetypes, path):
1241
1748
  return path
1242
1749
 
1243
1750
 
1244
- def contact_sheet(outdir, cands, path):
1751
+ def contact_sheet(outdir, cands, path, start_index=1):
1245
1752
  try:
1246
1753
  from PIL import Image, ImageDraw
1247
1754
  except Exception:
1248
1755
  return None
1249
1756
  cell, pad, cols = 220, 20, 4
1250
- items = cands[:12]
1757
+ items = cands # 上限由调用方定,编号与 BRIEF 表格一一对应
1251
1758
  if not items:
1252
1759
  return None
1253
1760
  rows = (len(items) + cols - 1) // cols
@@ -1271,19 +1778,95 @@ def contact_sheet(outdir, cands, path):
1271
1778
  dr.text((x + 8, y + 8), 'unreadable', fill=(200, 0, 0))
1272
1779
  dr.rectangle([x, y, x + cell, y + cell], outline=(120, 120, 128))
1273
1780
  dr.text((x + 2, y + cell + 4), '[%d] %s %dx%d used=%d'
1274
- % (idx + 1, c['file'], c['probe'].get('w') or 0, c['probe'].get('h') or 0, c['n']),
1781
+ % (c.get('_candidate_index', start_index + idx), c['file'],
1782
+ c['probe'].get('w') or 0,
1783
+ c['probe'].get('h') or 0, c['n']),
1275
1784
  fill=(20, 20, 24))
1276
1785
  sheet.save(path, optimize=True)
1277
1786
  return path
1278
1787
 
1279
1788
 
1789
+ def contact_sheets(outdir, cands, ldir):
1790
+ paths = []
1791
+ legacy = os.path.join(ldir, 'contact-sheet.png')
1792
+ if os.path.exists(legacy):
1793
+ os.remove(legacy)
1794
+ for start in range(0, len(cands), SHEET_BATCH):
1795
+ batch = cands[start:start + SHEET_BATCH]
1796
+ path = os.path.join(ldir, 'contact-sheet-%d.png' % (start // SHEET_BATCH + 1))
1797
+ if contact_sheet(outdir, batch, path, start + 1):
1798
+ paths.append(path)
1799
+ if paths:
1800
+ shutil.copy2(paths[0], legacy)
1801
+ return paths
1802
+
1803
+
1804
+ def asset_context_sheets(outdir, cands, ldir):
1805
+ """按候选主所在页去重拼整页语境,供模型识别 logo 墙和装饰用途。"""
1806
+ reviewed = [c for c in cands if needs_asset_judgment(c)]
1807
+ pages = []
1808
+ seen = set()
1809
+ for c in reviewed:
1810
+ page = next((no for no in c.get('slides') or [] if no and no != 9999), None)
1811
+ if page is not None and page not in seen:
1812
+ seen.add(page)
1813
+ pages.append(page)
1814
+ if not pages:
1815
+ return []
1816
+ import subprocess
1817
+ result = subprocess.run(
1818
+ [sys.executable, os.path.join(HERE, 'render_pages.py'), outdir,
1819
+ '--pages', 'slides', '--only', ','.join(map(str, pages)), '--no-html'],
1820
+ capture_output=True, text=True,
1821
+ )
1822
+ png_dir = os.path.join(outdir, 'ref', 'rebuild', 'png')
1823
+ if result.returncode or not os.path.isdir(png_dir):
1824
+ return []
1825
+ try:
1826
+ from PIL import Image, ImageDraw
1827
+ except Exception:
1828
+ return []
1829
+ paths = []
1830
+ candidate_ids = defaultdict(list)
1831
+ for index, c in enumerate(cands, 1):
1832
+ if not needs_asset_judgment(c):
1833
+ continue
1834
+ for page in c.get('slides') or []:
1835
+ if page in seen:
1836
+ candidate_ids[page].append(index)
1837
+ for start in range(0, len(pages), CONTEXT_BATCH):
1838
+ batch = pages[start:start + CONTEXT_BATCH]
1839
+ cols, cw, ch, pad, lab = 2, 480, 270, 16, 22
1840
+ rows = (len(batch) + cols - 1) // cols
1841
+ sheet = Image.new('RGB', (cols * (cw + pad) + pad,
1842
+ rows * (ch + pad + lab) + pad), (245, 245, 247))
1843
+ draw = ImageDraw.Draw(sheet)
1844
+ for offset, page in enumerate(batch):
1845
+ x = pad + (offset % cols) * (cw + pad)
1846
+ y = pad + (offset // cols) * (ch + pad + lab)
1847
+ source = os.path.join(png_dir, 'slide-%d.png' % page)
1848
+ if os.path.exists(source):
1849
+ image = Image.open(source).convert('RGB')
1850
+ image.thumbnail((cw, ch))
1851
+ sheet.paste(image, (x, y))
1852
+ draw.rectangle([x, y, x + cw, y + ch], outline=(120, 120, 128))
1853
+ draw.text((x + 2, y + ch + 5), 'slide %d candidates=%s'
1854
+ % (page, ','.join(map(str, candidate_ids[page]))),
1855
+ fill=(20, 20, 24))
1856
+ path = os.path.join(ldir, 'asset-context-sheet-%d.png'
1857
+ % (start // CONTEXT_BATCH + 1))
1858
+ sheet.save(path, optimize=True)
1859
+ paths.append(path)
1860
+ return paths
1861
+
1862
+
1280
1863
  # ---------------------------------------------------------------- 落盘
1281
1864
  def write(p, s):
1282
1865
  with open(p, 'w', encoding='utf-8') as f:
1283
1866
  f.write(s)
1284
1867
 
1285
1868
 
1286
- def emit_manifest(d, assets, ldir):
1869
+ def emit_manifest(d, assets, review_candidates, ldir):
1287
1870
  L = ['version: alpha',
1288
1871
  'name: TODO-style-name # 英文 kebab,体现气质,不要用文件名',
1289
1872
  'name_zh: TODO中文名',
@@ -1304,6 +1887,19 @@ def emit_manifest(d, assets, ldir):
1304
1887
  L.append(' on-bg: %s' % (a.get('on_bg') or 'light'))
1305
1888
  if a['use_full']:
1306
1889
  L.append(' use_full: true')
1890
+ if review_candidates:
1891
+ L += [
1892
+ 'asset_decisions:',
1893
+ ' # 每个局部图或半透明满屏叠加层都要结合候选图与整页语境定性。',
1894
+ ' # package.py 只把 texture|logo|icon|slogan 合并进 assets;content 不进包。',
1895
+ ]
1896
+ for index, c in enumerate(review_candidates, 1):
1897
+ if not needs_asset_judgment(c):
1898
+ continue
1899
+ L.append(' - source_media: %s' % c['file'])
1900
+ L.append(' decision: TODO-kind-%d # content|texture|logo|icon|slogan;'
1901
+ '候选 #%d,所在页 %s'
1902
+ % (index, index, ','.join(map(str, c['slides'][:6])) or 'layout'))
1307
1903
  write(os.path.join(ldir, 'manifest.yaml'), '\n'.join(L) + '\n')
1308
1904
 
1309
1905
 
@@ -1334,10 +1930,11 @@ def emit_frontmatter(d, tokens, fonts, roles, anchors, gaps, ldir):
1334
1930
  L.append('spacing:')
1335
1931
  L.append(' page-padding: {top: %s, right: %s, bottom: %s, left: %s}'
1336
1932
  % (edge['top'], edge['right'], edge['bottom'], edge['left']))
1337
- # 只排除「圆角量为零」(那是直角不是圆角),不再设「出现几次才算数」的门槛
1338
- radii = [r for r in (d.get('radii_census') or []) if r['px'] >= 1]
1339
- if radii:
1340
- top = max(radii, key=lambda r: r['n'])
1933
+ # rounded.card 是全局 token,只能表达全档共同的一档圆角。多个非零档位或零/非零
1934
+ # 混用时,圆角属于 layouts.md 里的局部形状事实,压成一个值会把直角容器也圆角化。
1935
+ radii = d.get('radii_census') or []
1936
+ if len(radii) == 1 and radii[0]['px'] >= 1:
1937
+ top = radii[0]
1341
1938
  L.append('rounded:')
1342
1939
  L.append(' card: %dpx' % round(top['px']))
1343
1940
  if edges_full:
@@ -1361,12 +1958,257 @@ def emit_frontmatter(d, tokens, fonts, roles, anchors, gaps, ldir):
1361
1958
  write(os.path.join(ldir, 'frontmatter.yaml'), '\n'.join(L) + '\n')
1362
1959
 
1363
1960
 
1364
- def emit_layouts(archetypes, ldir):
1961
+ def draft_flow(a, facts, canvas):
1962
+ """从结构事实推出「区带」草案:一页 = 若干竖直区带,高度由内容决定。
1963
+
1964
+ 绝对坐标只能表达「模板样张那份内容摆在哪」。真实内容长度不同,上面的区带一变高,
1965
+ 下面的就该整体下移——这件事在一张坐标表里表达不出来,只能靠消费端自己算,而它
1966
+ 算错的方向有两个:估小了压穿下一块,估大了留一片空。
1967
+
1968
+ 这里只出草案,最终用绝对还是流式由看得到重建图的人定。
1969
+ """
1970
+ cW, cH = canvas
1971
+ # 装饰件也算进来:很多模板的版式层只有几个占位符,真正撑起版面的是卡片容器
1972
+ # (在 decor 里)。只看 slots 会把一页的主体结构整个漏掉。
1973
+ slots = [s for s in a['slots'] if s.get('box')]
1974
+ fixed_roles = {'logo', 'slide-number', 'page-number', 'header', 'footer'}
1975
+ fixed = [s for s in slots if s.get('role') in fixed_roles]
1976
+ content_slots = [s for s in slots if s.get('role') not in fixed_roles]
1977
+ containers = [{'role': 'container', 'type': 'decor', 'box': dcr['box'],
1978
+ 'css': dcr.get('css')} for dcr in (a.get('decor') or [])]
1979
+ items = group_flow_cards(content_slots, containers)
1980
+ if len(items) < 2:
1981
+ return None
1982
+ items.sort(key=lambda s: (s['box'][1], s['box'][0]))
1983
+ gaps = [items[i + 1]['box'][1] - (items[i]['box'][1] + items[i]['box'][3])
1984
+ for i in range(len(items) - 1)]
1985
+ pos = [g for g in gaps if g > 0]
1986
+ if not pos:
1987
+ return None
1988
+ # 区带边界 = 间距分布里的最大空档。同一区带内部的间距(网格行距之类)总是明显
1989
+ # 小于区带之间的间距,用本页自己的分布切,不设固定阈值。
1990
+ cut = _gap_cut(pos, min(pos), max(pos)) if len(pos) > 1 else pos[0]
1991
+ regions, cur = [], [items[0]]
1992
+ for i, g in enumerate(gaps):
1993
+ if g >= cut:
1994
+ regions.append(cur)
1995
+ cur = []
1996
+ cur.append(items[i + 1])
1997
+ regions.append(cur)
1998
+
1999
+ # 整页左右边距 = 所有内容的横向外包络,作为各区带的缺省。
2000
+ lefts = [s['box'][0] for s in items]
2001
+ rights = [s['box'][0] + s['box'][2] for s in items]
2002
+ page_margin = [min(lefts), cW - max(rights)]
2003
+
2004
+ out = []
2005
+ for reg in regions:
2006
+ if not reg:
2007
+ continue
2008
+ # 同一区带里 y 接近的算一行;每行元素数一致且 >1 就是网格
2009
+ rows, cr = [], [reg[0]]
2010
+ for s in reg[1:]:
2011
+ if abs(s['box'][1] - cr[-1]['box'][1]) <= max(s['box'][3], 1) * 0.5:
2012
+ cr.append(s)
2013
+ else:
2014
+ rows.append(cr)
2015
+ cr = [s]
2016
+ rows.append(cr)
2017
+ widths = {len(r) for r in rows}
2018
+ if len(rows) >= 1 and widths == {len(rows[0])} and len(rows[0]) > 1:
2019
+ cols = len(rows[0])
2020
+ xs = sorted(s['box'][0] for s in rows[0])
2021
+ col_gap = round((xs[1] - xs[0]) - rows[0][0]['box'][2]) if cols > 1 else 0
2022
+ row_gap = 0
2023
+ if len(rows) > 1:
2024
+ row_gap = round(rows[1][0]['box'][1]
2025
+ - (rows[0][0]['box'][1] + rows[0][0]['box'][3]))
2026
+ region = {'kind': 'grid', 'cols': cols, 'gap': [max(col_gap, 0), max(row_gap, 0)],
2027
+ 'items': rows[0]}
2028
+ # 卡片组的横向范围常和整页不同(标题贴左、卡片居中)。整页边距是所有元素的
2029
+ # 外包络,直接套给居中卡片组会把它拉偏成左对齐。区带范围和整页明显不一致时,
2030
+ # 落这个区带自己的左右边距,消费端把网格放进它再填 1fr。按落盘的整数比较,
2031
+ # 亚像素噪声不触发多余的区带边距。
2032
+ reg_margin = [min(s['box'][0] for s in rows[0]),
2033
+ cW - max(s['box'][0] + s['box'][2] for s in rows[0])]
2034
+ if [int(reg_margin[0]), int(reg_margin[1])] != [int(page_margin[0]), int(page_margin[1])]:
2035
+ region['margin'] = reg_margin
2036
+ out.append(region)
2037
+ elif len(rows) == len(reg):
2038
+ # 每行一个元素 = 真的竖着排
2039
+ inner = 0
2040
+ if len(reg) > 1:
2041
+ inner = round(reg[1]['box'][1] - (reg[0]['box'][1] + reg[0]['box'][3]))
2042
+ out.append({'kind': 'stack', 'gap': max(inner, 0), 'items': reg})
2043
+ else:
2044
+ # 每行元素数不一致(比如左列两张、右列一张跨两行)。硬说成 stack 会让消费端
2045
+ # 以为它们是竖排的,比不给还糟。如实说这块推不出规整结构,按坐标摆。
2046
+ out.append({'kind': 'free', 'items': reg})
2047
+ if fixed:
2048
+ out.append({'kind': 'free', 'items': fixed})
2049
+ if len(out) < 2:
2050
+ return None
2051
+ return {'top': items[0]['box'][1], 'margin': page_margin,
2052
+ 'gap': round(cut), 'regions': out}
2053
+
2054
+
2055
+ def box_contains(outer, inner):
2056
+ return (outer[0] <= inner[0] and outer[1] <= inner[1]
2057
+ and outer[0] + outer[2] >= inner[0] + inner[2]
2058
+ and outer[1] + outer[3] >= inner[1] + inner[3])
2059
+
2060
+
2061
+ def boxes_overlap(a, b):
2062
+ return (min(a[0] + a[2], b[0] + b[2]) > max(a[0], b[0])
2063
+ and min(a[1] + a[3], b[1] + b[3]) > max(a[1], b[1]))
2064
+
2065
+
2066
+ def overlap_ratio(outer, inner):
2067
+ width = min(outer[0] + outer[2], inner[0] + inner[2]) - max(outer[0], inner[0])
2068
+ height = min(outer[1] + outer[3], inner[1] + inner[3]) - max(outer[1], inner[1])
2069
+ if width <= 0 or height <= 0 or inner[2] <= 0 or inner[3] <= 0:
2070
+ return 0
2071
+ return width * height / (inner[2] * inner[3])
2072
+
2073
+
2074
+ def group_flow_cards(slots, containers):
2075
+ """把并列卡片容器及其文字组成一层 group,避免拍平成多列元素。"""
2076
+ candidates = []
2077
+ for container in containers:
2078
+ children = [slot for slot in slots if box_contains(container['box'], slot['box'])]
2079
+ if len(children) >= 2:
2080
+ candidates.append((container, children))
2081
+ selected = []
2082
+ for container, children in sorted(
2083
+ candidates, key=lambda pair: pair[0]['box'][2] * pair[0]['box'][3]):
2084
+ if not any(boxes_overlap(container['box'], other['box']) for other, _ in selected):
2085
+ selected.append((container, children))
2086
+ if len(selected) < 2:
2087
+ return slots + containers
2088
+
2089
+ grouped_slots = {id(slot) for _, children in selected for slot in children}
2090
+ nested_by_container = {}
2091
+ for container, _ in selected:
2092
+ nested_by_container[id(container)] = [
2093
+ other for other in containers
2094
+ if other is not container and overlap_ratio(container['box'], other['box']) >= 0.9
2095
+ ]
2096
+ grouped_containers = {
2097
+ id(container)
2098
+ for container, _ in selected
2099
+ for container in [container] + nested_by_container[id(container)]
2100
+ }
2101
+ out = [slot for slot in slots if id(slot) not in grouped_slots]
2102
+ out += [container for container in containers if id(container) not in grouped_containers]
2103
+ for container, children in selected:
2104
+ children = children + nested_by_container[id(container)]
2105
+ children = sorted(children, key=lambda slot: (slot['box'][1], slot['box'][0]))
2106
+ gaps = [children[i + 1]['box'][1]
2107
+ - (children[i]['box'][1] + children[i]['box'][3])
2108
+ for i in range(len(children) - 1)]
2109
+ outer = container['box']
2110
+ insets = [
2111
+ min(child['box'][1] - outer[1] for child in children),
2112
+ min(outer[0] + outer[2] - child['box'][0] - child['box'][2] for child in children),
2113
+ min(outer[1] + outer[3] - child['box'][1] - child['box'][3] for child in children),
2114
+ min(child['box'][0] - outer[0] for child in children),
2115
+ ]
2116
+ padding = max(0, round(min(insets)))
2117
+ css = container.get('css') or ''
2118
+ if padding:
2119
+ css = '; '.join(part for part in (
2120
+ css.rstrip('; '), 'box-sizing: border-box', 'padding: %dpx' % padding) if part)
2121
+ out.append({
2122
+ 'role': 'group',
2123
+ 'type': 'group',
2124
+ 'box': outer,
2125
+ 'css': css,
2126
+ 'gap': max(0, round(min(gaps))) if gaps else 0,
2127
+ 'items': children,
2128
+ })
2129
+ return out
2130
+
2131
+
2132
+ def structure_facts(archetypes, d, shapes):
2133
+ """每个页型的**结构事实**:栅格、垂直间距序列、容器样式配方、样张里的实际字数。
2134
+
2135
+ 这些是判「该用绝对坐标还是流式」的依据,脚本只测不判:
2136
+ - 栅格拟合好不好,决定这页是不是一个规整的多列区带
2137
+ - 垂直间距序列里的突变点,就是区带的边界(网格内部 24、区带之间 110)
2138
+ - 样张字数说明这个框是按几行内容设计的——框高本身看不出这件事
2139
+ """
2140
+ q = _load_query()
2141
+ by_part = defaultdict(list)
2142
+ for s in shapes:
2143
+ by_part[s.get('part')].append(s)
2144
+
2145
+ # 容器样式配方:跨全档聚类一次,记出现次数与跨页数,供判断「哪些是共性风格」
2146
+ groups = {}
2147
+ for s in shapes:
2148
+ fill, line, fx = s.get('fill'), s.get('line'), s.get('effects')
2149
+ if not fill and not line and not fx:
2150
+ continue
2151
+ if isinstance(fill, dict) and fill.get('type') == 'image':
2152
+ continue
2153
+ k = q._sig(fill, line, fx)
2154
+ if k[0] == 'none' and k[1] == 'none' and not k[2]:
2155
+ continue
2156
+ g = groups.setdefault(k, {'n': 0, 'parts': set(), 'radii': [],
2157
+ 'fill': fill, 'line': line, 'fx': fx, 'shapes': set()})
2158
+ g['n'] += 1
2159
+ g['parts'].add(s.get('part'))
2160
+ g['radii'].append(s.get('radius_px') or 0)
2161
+ g['shapes'].add(id(s))
2162
+ ranked = sorted(groups.values(), key=lambda g: -g['n'])
2163
+ recipe_id = {}
2164
+ recipes = []
2165
+ for i, g in enumerate(ranked, 1):
2166
+ rid = 'r%d' % i
2167
+ css = [re.sub(r'\s*\n\s*', ' ', c.split('\x00')[0]).strip()
2168
+ for c in q._recipe_css(g['fill'], g['line'], g['radii'], g['fx']) if c]
2169
+ recipes.append({'id': rid, 'n': g['n'], 'pages': len(g['parts']),
2170
+ 'css': '; '.join(css)})
2171
+ for sid in g['shapes']:
2172
+ recipe_id[sid] = rid
2173
+
2174
+ grids = (d.get('spacing_candidates') or {}).get('grids') or []
2175
+ grid_by_part = defaultdict(list)
2176
+ for gd in grids:
2177
+ grid_by_part[gd.get('part')].append(gd)
2178
+
2179
+ out = {}
2180
+ for a in archetypes:
2181
+ part = None
2182
+ if a.get('source', '').startswith('layout:'):
2183
+ part = 'ppt/slideLayouts/' + a['source'].split(':', 1)[1]
2184
+ elif a.get('rep'):
2185
+ part = 'ppt/slides/slide%d.xml' % a['rep']
2186
+ boxes = [s['box'] for s in a['slots']] + [x['box'] for x in (a.get('decor') or [])]
2187
+ boxes.sort(key=lambda b: b[1])
2188
+ gaps = [boxes[i + 1][1] - (boxes[i][1] + boxes[i][3]) for i in range(len(boxes) - 1)]
2189
+ chars = [(s['box'], len(s.get('txt') or '')) for s in a['slots'] if s.get('txt')]
2190
+ used = []
2191
+ for s in by_part.get(part, []):
2192
+ rid = recipe_id.get(id(s))
2193
+ if rid and rid not in used:
2194
+ used.append(rid)
2195
+ out[a['name']] = {'grids': grid_by_part.get(part) or [], 'gaps': gaps,
2196
+ 'chars': chars, 'recipes': used}
2197
+ return out, recipes
2198
+
2199
+
2200
+ def emit_layouts(archetypes, ldir, busy_hints=None, facts=None, recipes=None):
1365
2201
  prefilled = sum(1 for a in archetypes if a.get('zh'))
1366
- L = ['# 只改 names / roles / bg_rules 三段(都是扁平键值,改完 package.py 自动并回各页型)。',
2202
+ L = ['# 判断单草案 —— package.py 读它产出 layouts.md,deck 的版式坐标从 layouts.md 读。',
2203
+ '# 只改 names / roles / text_roles / layout_modes / bg_rules 五段(都是扁平键值,'
2204
+ '改完 package.py 自动并回各页型)。',
1367
2205
  '# 下面 layouts 段是普查数值,一个字都不要动——改它容易连带删掉 slots/confidence。']
1368
2206
  if prefilled:
1369
2207
  L.append('# names 已按模板自带的版式名填好 %d 条,读一遍确认表意即可,通常不用改。' % prefilled)
2208
+ if recipes:
2209
+ L.append('# 容器样式配方(按出现次数排;跨页数多 = 共性风格,只在一处出现的多半不是):')
2210
+ for r in recipes[:8]:
2211
+ L.append('# %s 出现 %d 次 / 跨 %d 处 %s' % (r['id'], r['n'], r['pages'], r['css']))
1370
2212
  L.append('names:')
1371
2213
  for a in archetypes:
1372
2214
  if a.get('zh'):
@@ -1388,6 +2230,32 @@ def emit_layouts(archetypes, ldir):
1388
2230
  len([s for s in a['slots'] if not s.get('asset')]),
1389
2231
  '/'.join(str(x) for x in szs[:5]) or '未声明',
1390
2232
  a.get('pic_n') or 0, ';有满屏底图' if a.get('bg_raw') else ''))
2233
+ text_role_ids = {}
2234
+ for a in archetypes:
2235
+ index = 0
2236
+ for slot in a.get('slots') or []:
2237
+ if not slot.get('_needs_role'):
2238
+ continue
2239
+ index += 1
2240
+ text_role_ids[id(slot)] = '%s-text-%d' % (a['name'], index)
2241
+ if text_role_ids:
2242
+ L.append('text_roles: # 取值 title|subtitle|header|footer|body;只改角色,不删槽')
2243
+ for a in archetypes:
2244
+ for slot in a.get('slots') or []:
2245
+ role_id = text_role_ids.get(id(slot))
2246
+ if not role_id:
2247
+ continue
2248
+ L.append(' %s: TODO文本角色 # 来源 %s;占位符 %s;样例 %s;'
2249
+ 'box %s;字号 %s;css %s'
2250
+ % (role_id, slot.get('_source_layer') or '-',
2251
+ slot.get('_placeholder') or '-', q(slot.get('txt') or ''),
2252
+ slot.get('box'), round(slot.get('sz') or 0),
2253
+ q(slot.get('css') or '未声明')))
2254
+ flow_archetypes = [a for a in archetypes if a.get('flow')]
2255
+ if flow_archetypes:
2256
+ L.append('layout_modes: # 取值 flow|slots;内容会变的内容页优先 flow,固定构图页用 slots')
2257
+ for a in flow_archetypes:
2258
+ L.append(' %s: TODO布局模式 # 依据见 layouts 段该页型上方的结构事实' % a['name'])
1391
2259
  # 禁放区是**背景图**的属性,不是页型的属性——按背景资产分组,页型再多也不涨
1392
2260
  bgs = []
1393
2261
  for a in archetypes:
@@ -1398,25 +2266,138 @@ def emit_layouts(archetypes, ldir):
1398
2266
  for bg in bgs:
1399
2267
  users = [a['name'] for a in archetypes if a['bg'] == bg]
1400
2268
  L.append(' %s: # 用它的页型:%s' % (bg, ', '.join(users)))
1401
- L.append(' text_safe: TODO安全文字区[x,y,w,h],按这张背景的主体避让后填写')
2269
+ hint = (busy_hints or {}).get(bg)
2270
+ if hint:
2271
+ L.append(' # 图像局部对比度:中位 %s、九分位 %s;%s'
2272
+ % (hint['median'], hint['p90'],
2273
+ ('更花的一片在 %s' % hint['busy']) if hint.get('busy')
2274
+ else hint.get('why', '')))
2275
+ # text_safe 不是判断题:模板自己已经把文字放在哪儿写死了。取用这张背景的
2276
+ # 所有页型的槽与装饰件的外接并集即可——让人看图猜只会猜得更松,把模板从不
2277
+ # 放字的区域也划进安全区,这个字段就白设了。
2278
+ boxes = [s['box'] for a in archetypes if a['bg'] == bg for s in a['slots']] + \
2279
+ [dcr['box'] for a in archetypes if a['bg'] == bg for dcr in (a.get('decor') or [])]
2280
+ if boxes:
2281
+ x0 = min(b[0] for b in boxes)
2282
+ y0 = min(b[1] for b in boxes)
2283
+ x1 = max(b[0] + b[2] for b in boxes)
2284
+ y1 = max(b[1] + b[3] for b in boxes)
2285
+ L.append(' text_safe: [%d, %d, %d, %d] # 由该背景各页型的槽位并集算出'
2286
+ % (x0, y0, x1 - x0, y1 - y0))
2287
+ else:
2288
+ L.append(' text_safe: TODO安全文字区[x,y,w,h](该背景下没有任何槽位可依据)')
1402
2289
  L.append(' avoid: TODO禁放区列表;无禁放区写 [],有则写 [{box: [x,y,w,h], reason: "..."}]')
1403
2290
  L.append(' pairing_rule: "TODO这张背景上标题/正文/图表要避让哪些区域"')
1404
2291
  L.append('layouts:')
1405
2292
  for a in archetypes:
2293
+ fx = (facts or {}).get(a['name']) or {}
2294
+ if fx:
2295
+ # 结构事实:判「这页该用绝对坐标还是流式」的依据。脚本只测不判。
2296
+ for gd in (fx.get('grids') or [])[:2]:
2297
+ c, r = gd.get('cols') or {}, gd.get('rows') or {}
2298
+ L.append(' # 栅格:%s 列%s%s' % (
2299
+ c.get('n'), ' @%gpx 步距方差 %.2f' % (c.get('pitch') or 0, c.get('sd') or 0)
2300
+ if c.get('regular') else '(列不规整)',
2301
+ ',行 %s' % (('%d @%gpx' % (r.get('n') or 0, r.get('pitch') or 0))
2302
+ if r.get('regular') else '不规整')))
2303
+ if fx.get('gaps'):
2304
+ L.append(' # 垂直间距:%s(突变处即区带边界)'
2305
+ % '、'.join(str(int(g)) for g in fx['gaps'][:10]))
2306
+ if fx.get('chars'):
2307
+ L.append(' # 样张字数:%s'
2308
+ % '、'.join('%s=%d字' % (b, n) for b, n in fx['chars'][:6]))
2309
+ if fx.get('recipes'):
2310
+ L.append(' # 命中配方:%s' % '、'.join(fx['recipes'][:4]))
2311
+ # 槽与槽在坐标上重叠:PPT 里占位符互相压是常态(文字 valign 居中、样张只有一行,
2312
+ # 看不出来),照抄坐标做成 HTML 后内容一变长就撞。实测封面 title 框比 subtitle
2313
+ # 的顶还低 41px,两行标题直接压在副标题上。这里只报事实,怎么让开由你定。
2314
+ ov = slot_overlaps(a.get('slots') or [])
2315
+ if ov:
2316
+ L.append(' # 槽位重叠:%s(模板里靠文字居中不显形,内容变长会撞)'
2317
+ % '、'.join(ov[:3]))
1406
2318
  L.append(' %s:' % a['name'])
1407
2319
  if a.get('role'):
1408
2320
  L.append(' role: %s' % a['role'])
1409
2321
  if a['bg']:
1410
2322
  L.append(' background: %s' % a['bg'])
2323
+ fl = a.get('flow')
2324
+ if fl:
2325
+ L.append(' flow:')
2326
+ L.append(' top: %d' % fl['top'])
2327
+ L.append(' margin: [%d, %d]' % tuple(fl['margin']))
2328
+ L.append(' gap: %d' % fl['gap'])
2329
+ L.append(' regions:')
2330
+ for r in fl['regions']:
2331
+ if r['kind'] == 'grid':
2332
+ L.append(' - kind: grid')
2333
+ L.append(' cols: %d' % r['cols'])
2334
+ L.append(' gap: [%d, %d]' % tuple(r['gap']))
2335
+ if r.get('margin'):
2336
+ L.append(' margin: [%d, %d] # 本区带自己的左右边距,'
2337
+ '和整页 margin 不同(居中卡片组不跟标题的左边距)'
2338
+ % tuple(r['margin']))
2339
+ elif r['kind'] == 'free':
2340
+ L.append(' - kind: free # 推不出规整结构,按 slots 的坐标摆')
2341
+ else:
2342
+ L.append(' - kind: stack')
2343
+ L.append(' gap: %d' % r['gap'])
2344
+ L.append(' items:')
2345
+ for s in r['items']:
2346
+ if s.get('type') == 'group':
2347
+ L.append(' - role: group')
2348
+ L.append(' gap: %d' % s['gap'])
2349
+ if s.get('css'):
2350
+ L.append(' css: "%s"'
2351
+ % str(s['css']).replace('"', "'"))
2352
+ L.append(' items:')
2353
+ for child in s['items']:
2354
+ role_id = text_role_ids.get(id(child))
2355
+ if role_id:
2356
+ L.append(' # text-role: %s' % role_id)
2357
+ if child.get('type') == 'decor':
2358
+ L.append(' - {role: container, css: "%s"}'
2359
+ % str(child.get('css') or '').replace('"', "'"))
2360
+ continue
2361
+ extra = ''
2362
+ if child.get('css') is not None:
2363
+ extra += ', css: "%s"' % str(child['css']).replace('"', "'")
2364
+ if child.get('asset'):
2365
+ extra += ', asset: %s' % child['asset']
2366
+ if child.get('source_media'):
2367
+ extra += ', source_media: %s' % child['source_media']
2368
+ L.append(' - {role: %s, type: %s%s}'
2369
+ % (child['role'], child['type'], extra))
2370
+ continue
2371
+ role_id = text_role_ids.get(id(s))
2372
+ if role_id:
2373
+ L.append(' # text-role: %s' % role_id)
2374
+ # free 区带按坐标摆,而 slots 会被删掉,所以坐标必须写在这里
2375
+ bx = ', box: %s' % s['box'] if r['kind'] == 'free' else ''
2376
+ if s.get('type') == 'decor':
2377
+ L.append(' - {role: container%s, css: "%s"}'
2378
+ % (bx, (s.get('css') or '').replace('"', "'")))
2379
+ continue
2380
+ extra = bx
2381
+ if s.get('css') is not None:
2382
+ # CSS 串一律加引号:里面的逗号/冒号在 flow map 里是分隔符
2383
+ extra += ', css: "%s"' % str(s['css']).replace('"', "'")
2384
+ if s.get('asset'):
2385
+ extra += ', asset: %s' % s['asset']
2386
+ if s.get('source_media'):
2387
+ extra += ', source_media: %s' % s['source_media']
2388
+ L.append(' - {role: %s, type: %s%s}' % (s['role'], s['type'], extra))
1411
2389
  L.append(' slots:')
1412
2390
  for s in a['slots']:
2391
+ role_id = text_role_ids.get(id(s))
2392
+ if role_id:
2393
+ L.append(' # text-role: %s' % role_id)
1413
2394
  extra = ''
1414
2395
  if s.get('asset'):
1415
2396
  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)
2397
+ if s.get('source_media'):
2398
+ extra += ', source_media: %s' % s['source_media']
2399
+ if s.get('css') is not None:
2400
+ extra += ', css: "%s"' % str(s['css']).replace('"', "'")
1420
2401
  L.append(' - {role: %s, box: %s, type: %s%s}'
1421
2402
  % (s['role'], s['box'], s['type'], extra))
1422
2403
  if a.get('decor'):
@@ -1428,7 +2409,8 @@ def emit_layouts(archetypes, ldir):
1428
2409
  write(os.path.join(ldir, 'layouts.yaml'), '\n'.join(L) + '\n')
1429
2410
 
1430
2411
 
1431
- def emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, ldir):
2412
+ def emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, ldir,
2413
+ has_asset_candidates=False):
1432
2414
  """design.md 正文。
1433
2415
 
1434
2416
  每条规则只出现一次——同一条散在 Fast Path / Usage / Background Safety /
@@ -1437,37 +2419,63 @@ def emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, l
1437
2419
  """
1438
2420
  canvas = d['canvas']['px']
1439
2421
  cover = next((a for a in assets if a['id'] == 'bg-cover'), None)
1440
- logo = next((a for a in assets if a['kind'] == 'logo'), None)
1441
2422
  imp, webs = import_line(fonts)
1442
2423
  sidecar = '`layouts.md`'
1443
2424
 
1444
2425
  L = ['## Overview', '',
1445
2426
  'TODO: 两三句话讲清这套模板的性格与适用场景——看过联系表和页面重建图之后再写。', '']
1446
- L.append(('模板自带 %d 种版式,页型、坐标、字号、色值都直读自版式层。'
2427
+ L.append(('模板自带 %d 种版式,页型、坐标和 CSS 样式都直读自版式层。'
1447
2428
  % len(archetypes)) if (d.get('form_hint') or {}).get('form') == 3 else
1448
2429
  ('%d 页样张归纳出 %d 种页型。' % (d['counts']['slides'], len(archetypes))))
1449
2430
  L += ['', '## Usage', '',
1450
- '搭一页 PPT 五步,中间三步的数据都在 %s:' % sidecar, '']
2431
+ '搭一页 PPT 六步,中间四步的数据都在 %s:' % sidecar, '']
1451
2432
  L += ['1. **定画布** —— 舞台按 `layouts.md` 的 `canvas` 设成 %d×%d,'
1452
2433
  '别套用默认尺寸:源模板的长宽比不一定是 16:9,套错了整页坐标全偏。'
1453
2434
  '舞台尺寸改不了时,整体等比缩放 `min(舞台宽/%d, 舞台高/%d)` 后居中留白——'
1454
2435
  '逐轴拉伸会把圆压成椭圆、把字挤扁。' % (canvas[0], canvas[1], canvas[0], canvas[1]),
1455
2436
  '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`。'
2437
+ '页数多于页型时,挑最接近的一个原样套用它的 slot:用不到的槽删掉,'
2438
+ '内容比槽多就按同类槽的间距等距加,**坐标一律沿用该页型给的那套,不要自己另起网格**。'
2439
+ % sidecar,
2440
+ '3. **按页型给的形态落元素** —— 页型给 `flow` 就用流式,给 `slots` 就用绝对,'
2441
+ '两者只会出现一个。'
2442
+ '**flow**:整块用一个纵向 flex 容器,`top` 是它的起始 y,`margin` 是整块的左右边距,'
2443
+ '`gap` 是区带之间的间距;`regions` 从上往下依次排,**每个区带的高度由它自己的'
2444
+ '内容决定,不要写死高度**——上面的区带内容变多时,下面的自然被推下去,这正是'
2445
+ '这套表达要解决的事。区带内部:`kind: grid` 用 `grid-template-columns: repeat(cols, 1fr)` '
2446
+ '配 `gap: [行间距, 列间距]`;`kind: stack` 用纵向 flex 配 `gap`;`kind: free` '
2447
+ '按 item 自带的 `box` 绝对定位。区带自带 `margin: [左, 右]` 时用它的、'
2448
+ '覆盖整块的 `margin`(模板里居中的卡片组和贴左的标题横向范围本就不同);'
2449
+ '没带就用整块的 `margin`。`grid` 在自己这份左右边距里再 `repeat(cols, 1fr)`。'
2450
+ '`grid` 里的 `role: group` 是一张卡片:'
2451
+ 'group 的 `css` 用于外层容器,内部 `items` 按顺序纵向排布并使用 group 的 `gap`。'
2452
+ '每个 `role: container` 的项是容器,把它的 `css` 逐项原样写进 style,内容放进去;'
2453
+ '其中没有 `border-radius` 就按 `0`,不得自行补圆角。',
2454
+ '4. **按 slot 落元素(页型给的是 slots 时)** —— 每个 slot 渲染成一个绝对定位元素:`box` 是 '
2455
+ '`[x, y, w, h]`(%dx%d 画布上的绝对像素),机械展开成 `left/top/width/height`;'
2456
+ 'slot 的 `css` 是模板排版属性已转译好的声明串,原样写进 style,不要另选字号、'
2457
+ '内边距、颜色或对齐。'
1460
2458
  '带 `asset` 的 slot 是图片元素(logo、角标),把该资产放在它自己的 `box` 里;'
1461
2459
  '这个页型没有 `asset` 槽,这一页就不出现该资产。' % (canvas[0], canvas[1]),
1462
- '4. **铺装饰几何** —— 页型的 `decor` 是这一页的图形骨架(图标托底的圆、'
1463
- '卡片、分隔线):每条渲染成一个绝对定位空元素,`box` 给位置,`css` 原样写进 style,'
1464
- '`geom: ellipse` 另加 `border-radius: 50%`。它们压在背景之上、slot 之下,'
2460
+ '5. **铺装饰几何** —— 页型的 `decor` 是这一页的图形骨架(图标托底的圆、'
2461
+ '卡片、分隔线):每条渲染成一个绝对定位空元素,`box` 给位置,`css` 逐项原样写进 '
2462
+ 'style;没有 `border-radius` 就按 `0`。只有 `geom: ellipse` 另加 '
2463
+ '`border-radius: 50%`。它们压在背景之上、slot 之下,'
1465
2464
  '落在 slot 上的图标正是靠它们托住。',
1466
- '5. **配色与字体** —— 色板见下面 Colors 段,字体栈与 `@import` 见 Typography 段。']
1467
- if assets:
2465
+ '6. **落实全局设计** —— `design.md` frontmatter `colors`、`typography`、'
2466
+ '`spacing`、`rounded`、`components` 是全局 token;用 CSS variables、类名或内联'
2467
+ '样式承载。局部 slot / decor 的 `css` 优先,不能再解释成另一套视觉系统。'
2468
+ '字体使用 Typography 的完整栈与降级,不在运行时安装字体或依赖。',
2469
+ '7. **保持标题结构** —— 有合适页型可参考时,沿用该页型已有的标题层级与局部 '
2470
+ '`css`;只渲染该页型已有的文字槽,背景中已经可见的固定标题不再创建文本,'
2471
+ '页型没有 `subtitle` 槽就不新增副标题。没有合适参考时,按本包整体视觉组织标题。']
2472
+ if assets or has_asset_candidates:
1468
2473
  L += ['', '资产文件(背景由页型的 `background` 字段指定,'
1469
2474
  '图片资产的位置由该页型 `slots` 里带 `asset` 的槽给出):', '',
1470
- '{{ASSET_TABLE}}']
2475
+ '{{ASSET_TABLE}}', '',
2476
+ '将包内 `assets/` 复制到项目内相对目录,再引用复制后的路径;最终 HTML 不引用'
2477
+ '抽取工作目录或本机绝对路径。附件只提供 `assetRoot` / `assetPaths` 时,把'
2478
+ '`assetRoot` 当作不透明前缀,只拼接清单中声明的相对路径。']
1471
2479
  L += ['', '文字与容器的外接矩形落在该页型 `background` 对应的 `text_safe` 内,'
1472
2480
  '避开 `avoid` 列出的区域(两者都在 %s 的 `backgrounds` 段)。内容装不下时换页型或拆页。'
1473
2481
  % sidecar, '',
@@ -1482,7 +2490,8 @@ def emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, l
1482
2490
  ',源为商业/内部字体无 web 分发源,按气质降级到 %s' % f['stack'][1]
1483
2491
  if len(f['stack']) > 1 else ''))
1484
2492
  L += ['', '字号轴:' + '、'.join('%s %dpx' % (k, round(v['sz_px'])) for k, v in roles.items())
1485
- + '。slot 自带 `size` 时以 slot 为准;层级在轴上没有的,复用最接近的一档。', '',
2493
+ + '。slot 自带 `css` 时以其中的 `font-size` 为准;没有 slot CSS 的新增层级,'
2494
+ '复用轴上最接近的一档。', '',
1486
2495
  '字体加载(**HARD REQUIREMENT:下面这行 @import 原样写入全局样式首行,禁止替换为 '
1487
2496
  'fonts.googleapis.com 或其他域**):', '', '```', imp, '```', '',
1488
2497
  '镜像只保证 wght 400 一档,更粗的字重由浏览器合成,字重不能作为唯一区分手段;'
@@ -1494,15 +2503,20 @@ def emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, l
1494
2503
  L.append('- 封面页铺满 `bg-cover`,整幅覆盖 %dx%d 画布。' % (canvas[0], canvas[1]))
1495
2504
  if any(a['role'] == 'content' for a in assets):
1496
2505
  L.append('- 内容页的背景由该页型的 `background` 字段指定,整幅铺满。')
1497
- if logo:
1498
- L.append('- `%s` 的位置来自各页型 `slots` 里 `role: logo` 的 `box`——原样使用该文件,'
1499
- '保持原比例。' % logo['id'])
2506
+ L.append('{{LOGO_RULES}}')
1500
2507
  L += ['- 坐标、字号、色值、资产位置以 %s 为准;本文件的 Colors / Typography 是可用值的清单。'
1501
2508
  % sidecar,
1502
- '- 内容语义色(增长绿、下降红之类)本模板没有:用色板内颜色的深浅或透明度表达正负。',
2509
+ '- 强调色族以 Colors 和 %s 的 slot CSS 为主;必要时可以使用 Colors 之外的颜色,'
2510
+ '但不能形成与模板主色竞争的第二强调色。' % sidecar,
2511
+ '- 新增颜色应与模板整体的色相、明度和饱和度关系协调。允许新增中性色、低彩度辅助色'
2512
+ '或局部语义色表达正负、风险、警告、状态、图表序列,但保持辅助层级;'
2513
+ '只要新色通过高饱和、高对比、大面积或跨页重复获得主视觉权重,'
2514
+ '或被用于标题、关键数字、图表主序列、卡片底色或渐变,就属于新的强调色,改用模板'
2515
+ '强调色族的深浅、透明度,或改用线型、纹理、标签区分。',
2516
+ '- 交付前逐页检查:色板、字体、版式、背景、资产和本段规则均来自本风格包;'
2517
+ '页面无资源加载失败、内容溢出或画幅裁切。',
1503
2518
  '- 本包里的数值就是普查结果,照用即可,无需重新统计颜色、字体或版式。',
1504
2519
  '- 风格包以文本形式(zip 摘要等)到手时,直接用摘要里 design.md / layouts.md 的文本。',
1505
- '- TODO: 补 1-2 条这套模板特有的硬规则(看过重建图之后写,例如主色只许用在哪类元素)。',
1506
2520
  '', '## Exceptions', '']
1507
2521
  if exceptions:
1508
2522
  L += ['- ' + e for e in exceptions]
@@ -1513,28 +2527,55 @@ def emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, l
1513
2527
 
1514
2528
 
1515
2529
  def emit_brief(d, ctx, ldir):
1516
- (tokens, rest, fonts, roles, assets, rejected, todos, archetypes, cands, sheet,
1517
- leftover, lsheet) = ctx
2530
+ (tokens, rest, fonts, roles, assets, rejected, todos, archetypes, cands, sheets,
2531
+ context_sheets, leftover, lsheet) = ctx
1518
2532
  canvas = d['canvas']['px']
1519
- L = ['# 抽取简报(草案已生成,读完这一页就能改)', '',
2533
+ L = ['# 抽取简报(第 1/3 步产物;改完草案跑 package.py 出包)', '',
1520
2534
  '源:`%s` 画布 %dx%d %d 页 / %d 版式 主题 %s form=%s'
1521
2535
  % (d['source']['filename'], canvas[0], canvas[1], d['counts']['slides'],
1522
2536
  d['counts']['layouts'], d['theme_topology']['themes'],
1523
2537
  d['form_hint']['form']), '',
1524
2538
  '## 待判断(草案里已标 TODO,逐条改掉)', '']
1525
- base_todos = ['给风格起名:`manifest.yaml` 的 name / name_zh / description(看两张图定气质)',
1526
- '`layouts.yaml` 顶部 `names:` 一段填 %d 个中文页型名(看 layout-sheet.png,'
1527
- '一次改完;下面 layouts 段不要动)' % len(archetypes),
1528
- '`body.md` Overview 与 Hard Rules 末条(Colors 用途列草案已填好,觉得不对再改)']
1529
- for t in base_todos + todos:
2539
+ # 待判断清单从草案实时扫 TODO 生成,不写死:写死的清单会和草案对不上——
2540
+ # 既漏掉后加的段(模型读到一半才发现还有活),又在草案已预填时还催人去填。
2541
+ HINT = {'manifest.yaml': '看两张图定气质',
2542
+ 'layouts.yaml': '看 layout-sheet.png;layouts 段本身不要动',
2543
+ 'body.md': 'Colors 用途列草案已填好,觉得不对再改'}
2544
+ for fn in ('manifest.yaml', 'body.md', 'layouts.yaml', 'frontmatter.yaml'):
2545
+ path = os.path.join(ldir, fn)
2546
+ if not os.path.exists(path):
2547
+ continue
2548
+ keys = []
2549
+ for line in open(path, encoding='utf-8'):
2550
+ if 'TODO' not in line:
2551
+ continue
2552
+ m = re.match(r'\s*[-#]?\s*([\w-]+):', line)
2553
+ keys.append(m.group(1) if m else line.strip()[:24])
2554
+ if not keys:
2555
+ continue
2556
+ seen, uniq = set(), []
2557
+ for k in keys:
2558
+ if k not in seen:
2559
+ seen.add(k)
2560
+ uniq.append(k)
2561
+ hint = HINT.get(fn)
2562
+ L.append('- `%s` %d 处:%s%s'
2563
+ % (fn, len(keys), '、'.join(uniq[:6]) + ('…' if len(uniq) > 6 else ''),
2564
+ '(%s)' % hint if hint else ''))
2565
+ for t in todos:
1530
2566
  L.append('- ' + t)
1531
- L += ['', '## 联系表(一次看完所有候选图)', '',
1532
- '`l-out/contact-sheet.png` —— 编号对应下表;看完再决定 logo / 封面归属。' if sheet
1533
- else '(Pillow 不可用,未生成联系表;逐张看 `media-out/`)', '',
2567
+ L += ['', '## 资产判断(同一轮并行看完)', '',
2568
+ ('候选图:%s。图格编号对应下表;每张都要定性。'
2569
+ % '、'.join('`l-out/%s`' % os.path.basename(path) for path in sheets))
2570
+ if sheets else '(Pillow 不可用,未生成联系表;逐张看 `media-out/`)',
2571
+ ('整页语境:%s。按页去重,用来判断局部图是内容、装饰,还是 logo 墙中的第三方 logo。'
2572
+ % '、'.join('`l-out/%s`' % os.path.basename(path) for path in context_sheets))
2573
+ if context_sheets else '(没有可用的实例页整页语境;按候选图和版式图判断。)',
2574
+ '',
1534
2575
  '| # | 文件 | 尺寸 | 出现 | 满屏 | 页 | 草案判定 |', '|---|---|---|---|---|---|---|']
1535
2576
  decided = {a['src']['file']: a['id'] for a in assets}
1536
2577
  why = {c['file']: r for c, r in rejected}
1537
- for i, c in enumerate(cands[:12], 1):
2578
+ for i, c in enumerate(cands, 1):
1538
2579
  L.append('| %d | `%s` | %sx%s | %d | %s | %s | %s |' % (
1539
2580
  i, c['file'], c['probe'].get('w') or '?', c['probe'].get('h') or '?', c['n'],
1540
2581
  'Y' if c['fullscreen'] else '', ','.join(map(str, c['slides'][:6])) or 'layout',
@@ -1564,13 +2605,13 @@ def emit_brief(d, ctx, ldir):
1564
2605
  if leftover:
1565
2606
  L += ['', '未归入 archetype 的页:%s —— 都是单页孤例,需要就自己补一个 archetype。'
1566
2607
  % ', '.join(map(str, leftover))]
1567
- L += ['', '各 archetype 的 slot 原文(据此起中文页型名、改 role):', '']
2608
+ L += ['', '各 archetype 的 slot 原文(据此起中文页型名,并在 text_roles 判断文本角色):', '']
1568
2609
  for a in archetypes:
1569
2610
  L.append('- `%s`(第 %s 页,覆盖 %s)' % (a['name'], a['rep'], a['pages']))
1570
2611
  for s in a['slots']:
1571
2612
  L.append(' - %s %spx 「%s」' % (s['role'], round(s['sz']), s['txt']))
1572
2613
  L += ['', '## 下一步', '',
1573
- '1. `contact-sheet.png` 和 `layout-sheet.png`;'
2614
+ '1. 并行看全部 `contact-sheet-*.png`、`asset-context-sheet-*.png` 和 `layout-sheet.png`;'
1574
2615
  '2. 用一次批量编辑/patch 改掉四份草案里的 TODO;3. 跑 `package.py`。']
1575
2616
  write(os.path.join(ldir, 'BRIEF.md'), '\n'.join(L) + '\n')
1576
2617
 
@@ -1589,11 +2630,22 @@ def main(argv=None):
1589
2630
  cusage = color_usage(all_shapes, d)
1590
2631
  tokens, rest, rows = draft_colors(d, cusage)
1591
2632
  fonts = draft_fonts(d)
1592
- archetypes, pages, leftover = draft_layouts(d, outdir)
2633
+ effective_alpha = fullscreen_effective_alpha(d, outdir, all_shapes)
2634
+ archetypes, pages, leftover = draft_layouts(d, outdir, effective_alpha)
2635
+ # 封面底图:form=3 的页型键就是角色名(cover/section/...),直接按名字取。
2636
+ # form=2 按样张聚类,键是 layout-1..N,永远匹配不上 'cover'——实测 vo-lite 因此
2637
+ # 一张 role: cover 都没有,封面主视觉被标成 bg-content-1,消费端拿不到封面资产,
2638
+ # design.md 的「封面底图必用 cover 资产」这条硬规则无从满足。回退到覆盖第 1 页的
2639
+ # 那个页型:deck 的第 1 页就是封面,这是版式无关的事实。
1593
2640
  cover_media = next((a['bg_raw'] for a in archetypes if a['name'] == 'cover'), None)
1594
- bg_needed = {a['bg_raw'] for a in archetypes if a['bg_raw'] and a['bg_raw'].startswith('ppt/media')}
1595
- bg_under = {p['no']: p['bg_media'] for p in pages}
1596
- assets, rejected, todos, alias, pool = draft_assets(d, outdir, bg_needed, cover_media, bg_under)
2641
+ if not cover_media:
2642
+ cover_media = next((a['bg_raw'] for a in archetypes
2643
+ if 1 in (a.get('pages') or ())), None)
2644
+ exported_media = {m['media'] for m in d.get('media', []) if m.get('exported')}
2645
+ bg_needed = {a['bg_raw'] for a in archetypes if a['bg_raw'] in exported_media}
2646
+ bg_under = {p['no']: p.get('rendered_bg') or p['bg_media'] for p in pages}
2647
+ assets, rejected, todos, alias, pool = draft_assets(
2648
+ d, outdir, bg_needed, cover_media, bg_under, effective_alpha)
1597
2649
  media_to_asset = {a['src']['media']: a['id'] for a in assets}
1598
2650
  for m, w in (alias or {}).items():
1599
2651
  if w in media_to_asset:
@@ -1623,7 +2675,6 @@ def main(argv=None):
1623
2675
  assets.append({'id': aid, 'kind': 'icon', 'role': None, 'src': c, 'use_full': False})
1624
2676
  media_to_asset[c['media']] = aid
1625
2677
  media_to_asset[m] = aid
1626
- dropped_slots = []
1627
2678
  for a in archetypes:
1628
2679
  a['bg'] = media_to_asset.get(a['bg_raw'])
1629
2680
  # 版式自带的图片元素:映射到资产 id。映射不到时**保留槽位但不写 asset**——
@@ -1634,21 +2685,41 @@ def main(argv=None):
1634
2685
  if s.get('media'):
1635
2686
  aid = media_to_asset.get(s['media'])
1636
2687
  if not aid:
1637
- s['role'] = 'icon'
2688
+ c = pool.get(alias.get(s['media'], s['media'])) or pool.get(s['media'])
2689
+ s['role'] = 'asset-candidate'
2690
+ if c:
2691
+ s['source_media'] = c['file']
1638
2692
  s.pop('media', None)
1639
- dropped_slots.append((a['name'], s['box']))
1640
2693
  keep.append(s)
1641
2694
  continue
1642
2695
  s['asset'] = aid
2696
+ c = pool.get(alias.get(s['media'], s['media'])) or pool.get(s['media'])
2697
+ if c:
2698
+ s['source_media'] = c['file']
1643
2699
  # role 跟着资产走:图标槽写成 logo 会让消费端把它当品牌标识,每页都摆一个
1644
2700
  s['role'] = next((x['kind'] for x in assets if x['id'] == aid), s['role'])
1645
2701
  keep.append(s)
1646
2702
  a['slots'] = keep
1647
2703
  roles = draft_scale(d, archetypes)
1648
2704
  slot_added = cover_slot_colors(tokens, archetypes, rows, cusage)
1649
- cands = sorted([c for c in [a['src'] for a in assets]] +
1650
- [c for c, _ in rejected], key=lambda c: (-c['n'], c['file']))
1651
- sheet = contact_sheet(outdir, cands, os.path.join(ldir, 'contact-sheet.png'))
2705
+ # 全部候选都必须上联系表:装饰图与内容图不能靠尺寸/频次可靠区分,logo 墙更必须结合
2706
+ # 整页语境看。按批次出多张图而不是截断,模型可并行看完,不增加串行判断轮次。
2707
+ decided_c = sorted([a['src'] for a in assets], key=lambda c: (-c['n'], c['file']))
2708
+ other_c = sorted([c for c, _ in rejected], key=lambda c: (-c['n'], c['file']))
2709
+ cands, seen_file = [], set()
2710
+ for c in decided_c + other_c: # 同一张图可能有多条候选记录(不同位置各一条)
2711
+ if c['file'] not in seen_file:
2712
+ seen_file.add(c['file'])
2713
+ cands.append(c)
2714
+ decided_files = {c['file'] for c in decided_c}
2715
+ visual_candidates = []
2716
+ for candidate_index, candidate in enumerate(cands, 1):
2717
+ if needs_asset_judgment(candidate) or candidate['file'] in decided_files:
2718
+ row = dict(candidate)
2719
+ row['_candidate_index'] = candidate_index
2720
+ visual_candidates.append(row)
2721
+ sheets = contact_sheets(outdir, visual_candidates, ldir)
2722
+ context_sheets = asset_context_sheets(outdir, cands, ldir)
1652
2723
  lsheet = layout_sheet(outdir, archetypes, os.path.join(ldir, 'layout-sheet.png'))
1653
2724
 
1654
2725
  anchors = draft_anchors(d, tokens, fonts, roles, assets, archetypes)
@@ -1656,10 +2727,21 @@ def main(argv=None):
1656
2727
  for c, why in rejected:
1657
2728
  if '近全透明' in why:
1658
2729
  gaps.append('母版/版式里的 %s 是%s,不是设计资产,任何情况下不要当背景用。' % (c['file'], why))
1659
- if dropped_slots:
1660
- gaps.append('这些图标槽的源图没有随包分发(超出图标配额或不适合进包):%s'
1661
- '槽位保留了坐标,渲染时留空或用中性占位,不要自造图形去填。'
1662
- % '、'.join('%s %s' % (n, b) for n, b in dropped_slots[:8]))
2730
+ elif '不是背景' in why:
2731
+ gaps.append('%s 在模板里铺满整页,但%s;那几页的真实背景是幻灯片自身的底色,'
2732
+ '需要时按 Colors 里的 surface 铺纯色。' % (c['file'], why))
2733
+ by_kind = {}
2734
+ for kind, kept, total, advice, where in _TRUNCATED:
2735
+ e = by_kind.setdefault(kind, {'kept': 0, 'total': 0, 'advice': advice, 'where': []})
2736
+ e['kept'] += kept
2737
+ e['total'] += total
2738
+ if where:
2739
+ e['where'].append(where)
2740
+ for kind, e in by_kind.items():
2741
+ at = ('(%s)' % '、'.join(e['where'][:6])) if e['where'] else ''
2742
+ gaps.append('%s%s按名额截断:普查到 %d 个,包内留了 %d 个%s。'
2743
+ % (kind, at, e['total'], e['kept'],
2744
+ ';' + e['advice'] if e['advice'] else ''))
1663
2745
  # 「没命中映射表」不等于「装不上」:降级目标本身(Noto Sans SC 之类)和 Office 出厂体
1664
2746
  # 都不在 match 列里,但它们本来就可用。真正危险的是**既没命中、又不是已知可用字体**的
1665
2747
  # 那种——design.md 的字体栈里留着一个消费端装不上的商业字体名,且没有任何降级说明。
@@ -1676,7 +2758,7 @@ def main(argv=None):
1676
2758
  gaps.append('源字体 %s 不在 font-fallback 表里,字体栈只有原名,消费端很可能装不上;'
1677
2759
  '按气质挑一个有 web 分发源的近似体补进栈,不要照抄原名。' % f['names'][0])
1678
2760
  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')]
2761
+ if not s.get('asset') and not s.get('_font_size')]
1680
2762
  if nosize:
1681
2763
  gaps.append('这些文字槽在源文件任何层级都没有字号声明(都不是占位符,是普通文本框,'
1682
2764
  '继承源是 presentation.xml 的 defaultTextStyle,本抽取按约定不解继承链):'
@@ -1687,17 +2769,34 @@ def main(argv=None):
1687
2769
  exceptions.append('源 deck 第 %s 页是单页孤例,没有归纳成 archetype;需要类似构图时按最接近的页型改。'
1688
2770
  % '、'.join(map(str, leftover)))
1689
2771
 
1690
- emit_manifest(d, assets, ldir)
2772
+ emit_manifest(d, assets, cands, ldir)
1691
2773
  emit_frontmatter(d, tokens, fonts, roles, anchors, gaps, ldir)
1692
- emit_layouts(archetypes, ldir)
1693
- emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, ldir)
1694
- emit_brief(d, (tokens, rest, fonts, roles, assets, rejected, todos, archetypes, cands, sheet,
1695
- leftover, lsheet), ldir)
1696
-
1697
- print('草案就绪 -> %s' % ldir)
1698
- print(' 资产 %d(%s) 版式 %d 色 %d 字体 %d'
2774
+ # 每张背景量一次局部对比度,作为「哪里不能压文字」的客观依据摆进判断单。
2775
+ # 只报测到的数,不替人填 avoid——哪块算主体、要不要避让,是看图才能定的。
2776
+ busy_hints = {}
2777
+ for a in assets:
2778
+ if a['kind'] != 'background' or not a['src'].get('out'):
2779
+ continue
2780
+ r = bg_busy_map(os.path.join(outdir, a['src']['out']), (cW, cH))
2781
+ if r:
2782
+ busy_hints[a['id']] = r
2783
+ facts, recipes = structure_facts(archetypes, d, all_shapes)
2784
+ for a in archetypes:
2785
+ a['flow'] = draft_flow(a, facts.get(a['name']) or {}, (cW, cH))
2786
+ emit_layouts(archetypes, ldir, busy_hints, facts, recipes)
2787
+ emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, ldir,
2788
+ has_asset_candidates=any(needs_asset_judgment(c) for c in cands))
2789
+ emit_brief(d, (tokens, rest, fonts, roles, assets, rejected, todos, archetypes, cands, sheets,
2790
+ context_sheets, leftover, lsheet), ldir)
2791
+
2792
+ # 这几行落在模型判断「skill 是不是做完了」的那一刻。只报数就会被读成「包已生成」,
2793
+ # 于是判断和打包整段被跳过,deck 拿不到任何版式坐标。所以这里报进度与下一条命令。
2794
+ print('第 1/3 步完成,判断单草案 -> %s' % ldir)
2795
+ print(' 待你确认:资产 %d(%s) 版式 %d 色 %d 字体 %d'
1699
2796
  % (len(assets), ', '.join(x['id'] for x in assets), len(archetypes), len(tokens), len(fonts)))
1700
- print(' 先读 l-out/BRIEF.md,再看 l-out/contact-sheet.png')
2797
+ print(' 2 步 读 l-out/BRIEF.md,并行看联系表与整页语境图,改掉草案里的 TODO')
2798
+ print(' 第 3 步 package.py 产出 design.md + layouts.md —— deck 的版式坐标只从这两份读')
2799
+ sys.stdout.flush()
1701
2800
  return 0
1702
2801
 
1703
2802