@lark-apaas/coding-steering 0.1.38 → 0.1.39-alpha.20260824122813

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 +6 -6
  2. package/steering/design-html/skills/pptx-style-extract/SKILL.md +19 -28
  3. package/steering/design-html/skills/pptx-style-extract/scripts/census.py +2 -8
  4. package/steering/design-html/skills/pptx-style-extract/scripts/draft.py +190 -1181
  5. package/steering/design-html/skills/pptx-style-extract/scripts/extract.py +10 -229
  6. package/steering/design-html/skills/pptx-style-extract/scripts/ooxml.py +1 -18
  7. package/steering/design-html/skills/pptx-style-extract/scripts/package.py +40 -643
  8. package/steering/design-html/skills/pptx-style-extract/scripts/parts.py +3 -19
  9. package/steering/design-html/skills/pptx-style-extract/scripts/render_pages.py +2 -4
  10. package/steering/design-html/skills/pptx-style-extract/scripts/test_background_composite.py +1 -308
  11. package/steering/design-html/skills/pptx-style-extract/scripts/test_design_consumer_contract.py +0 -14
  12. package/steering/design-html/skills/pptx-style-extract/scripts/test_flow_layout_contract.py +7 -157
  13. package/steering/design-html/skills/pptx-style-extract/scripts/test_layout_css.py +2 -1598
  14. package/steering/design-html/skills/pptx-style-extract/scripts/test_text_role_contract.py +4 -151
  15. package/steering/design-html/skills/pptx-style-extract/v2-format-spec.md +1 -1
  16. package/steering/design-html/skills/preflight/scripts/probe.sh +0 -0
  17. package/steering/nestjs-react-fullstack/skills/coding-guide/SKILL.md +0 -11
  18. package/steering/nestjs-react-fullstack/skills/contacts-service/SKILL.md +4 -67
  19. package/steering/nestjs-react-fullstack/skills/nestjs-cache/SKILL.md +1 -0
  20. package/steering/nestjs-react-fullstack/skills/openapi-guide/SKILL.md +0 -75
  21. package/steering/nestjs-react-fullstack/skills_local/coding-guide/SKILL.md +7 -5
  22. package/steering/design-html/skills/pptx-style-extract/scripts/test_asset_judgment_package.py +0 -556
  23. package/steering/design-html/skills/pptx-style-extract/scripts/test_logo_scope.py +0 -479
  24. package/steering/design-html/skills/pptx-style-extract/scripts/verify_layout_assets.py +0 -400
  25. package/steering/design-html/skills/pptx-style-extract/scripts/verify_logo_scope.py +0 -12
@@ -5,22 +5,18 @@
5
5
 
6
6
  产出 <stage1-outdir>/l-out/:
7
7
  BRIEF.md 唯一必读简报:事实 + 草案依据 + 待判断清单
8
- vision-group-*.jpg 候选图与所在页语境拼版
9
- asset-vision-groups.json 候选来源、位置与预算索引
10
- manifest.yaml / frontmatter.yaml / layout-controls.yaml / layouts.yaml / body.md
11
- 判断单与坐标事实,可直接进 package.py
8
+ contact-sheet.png 候选图拼版(带编号,一次看完所有图)
9
+ manifest.yaml / frontmatter.yaml / layouts.yaml / body.md 四件草案,可直接进 package.py
12
10
 
13
11
  草案里所有数值都来自 extract.json;凡是需要「像人一样看」才能定的,写成 `TODO:` 行
14
12
  (package.py 见 TODO 即 FAIL),由 L 层改掉。
15
13
  """
16
14
  import argparse
17
15
  import copy
18
- import glob
19
16
  import json
20
17
  import os
21
18
  import re
22
19
  import shutil
23
- import subprocess
24
20
  import sys
25
21
  from collections import Counter, defaultdict
26
22
 
@@ -33,18 +29,7 @@ OPAQUE_ENOUGH = 128 # 能当背景的最低不透明度:低于半透明就
33
29
  # 那是叠加装饰不是背景
34
30
  FILL_MANY = 5 # 「被大量当填充铺开」的次数下限,用于区分卡片底与偶发用色
35
31
  BG_CONTENT_CAP = 5 # 内容页背景收几张:再多消费端也挑不过来,超出的写进 TODO 交人取舍
36
- SHEET_BATCH = 12 # 每张联系表最多 12 个候选;候选不截断,超出就继续生成下一张
37
- CONTEXT_BATCH = 8 # 每张整页语境表最多 8 页;同页只渲染一次
38
- # 与 studio_server_faas 的批量 vision 图数预算保持一致。单页大组允许 1 张整页图 +
39
- # 9 张候选;多个小页合组时,所有整页图和候选图合计最多 5 张。
40
- SINGLE_PAGE_IMAGE_BUDGET = 10
41
- MULTI_PAGE_IMAGE_BUDGET = 5
42
- # Skill 侧的总输入上限:限制模型需要读取的拼版数量和总视觉图数,而不是偷偷截断
43
- # 中间产物。首页、尾页优先;其余超限候选在 gaps 中显式说明。
44
- VISUAL_PACK_CAP = 5
45
- VISUAL_INPUT_CAP = 30
46
- VISUAL_PREVIEW_MAX_EDGE = 1200
47
- VISUAL_JPEG_QUALITY = 82
32
+ SHEET_CAP = 12 # 联系表展示上限;进包的资产不受它约束,一张都不截
48
33
 
49
34
  HERE = os.path.dirname(os.path.abspath(__file__))
50
35
  SKILL_ROOT = os.path.dirname(HERE)
@@ -613,13 +598,7 @@ def lh_of(t):
613
598
 
614
599
  # ---------------------------------------------------------------- 资产
615
600
  def probe_image(path):
616
- info = {
617
- 'w': None,
618
- 'h': None,
619
- 'alpha_mean': None,
620
- 'near_blank': False,
621
- 'near_white_ratio': None,
622
- }
601
+ info = {'w': None, 'h': None, 'alpha_mean': None, 'near_blank': False}
623
602
  try:
624
603
  from PIL import Image
625
604
  except Exception:
@@ -627,69 +606,15 @@ def probe_image(path):
627
606
  try:
628
607
  im = Image.open(path)
629
608
  info['w'], info['h'] = im.size
630
- preview = im.convert('RGBA').resize((64, 64))
631
- pixels = (preview.get_flattened_data()
632
- if hasattr(preview, 'get_flattened_data') else preview.getdata())
633
- px = list(pixels)
634
- alpha = [item[3] for item in px]
635
609
  if im.mode in ('RGBA', 'LA') or 'transparency' in im.info:
636
- info['alpha_mean'] = sum(alpha) / len(alpha)
610
+ px = im.convert('RGBA').getchannel('A').resize((64, 64)).tobytes()
611
+ info['alpha_mean'] = sum(px) / len(px)
637
612
  info['near_blank'] = info['alpha_mean'] < 13 # <5% 不透明度
638
- visible = [item for item in px if item[3] >= 32]
639
- if visible:
640
- near_white = [item for item in visible
641
- if item[0] >= 235 and item[1] >= 235 and item[2] >= 235]
642
- info['near_white_ratio'] = round(len(near_white) / float(len(visible)), 3)
643
613
  except Exception:
644
614
  pass
645
615
  return info
646
616
 
647
617
 
648
- def needs_asset_judgment(candidate):
649
- """局部图和半透明满屏叠加层需要看图定性;不透明满屏图按背景处理。"""
650
- effective_alpha = candidate.get('effective_alpha_mean')
651
- if not candidate.get('fullscreen'):
652
- return True
653
- alpha = (effective_alpha if effective_alpha is not None
654
- else (candidate.get('probe') or {}).get('alpha_mean'))
655
- return alpha is not None and 13 <= alpha < OPAQUE_ENOUGH
656
-
657
-
658
- def fullscreen_effective_alpha(data, outdir, shapes):
659
- """满屏图片的实际平均 alpha,包含图片文件 alpha 与 OOXML 形状透明度。"""
660
- media_out = {row.get('media'): row.get('out') for row in data.get('media') or []
661
- if row.get('media') and row.get('out')}
662
- probed = {}
663
- effective = {}
664
- for shape in shapes:
665
- media = shape.get('media')
666
- if (shape.get('kind') != 'pic' or not media
667
- or shape.get('w_pct', 0) < 95 or shape.get('h_pct', 0) < 95):
668
- continue
669
- if media not in probed:
670
- out = media_out.get(media)
671
- probe = probe_image(os.path.join(outdir, out)) if out else {}
672
- probed[media] = probe.get('alpha_mean')
673
- source_alpha = probed[media]
674
- if source_alpha is None:
675
- source_alpha = 255.0
676
- try:
677
- opacity = float(shape.get('opacity', 1.0))
678
- except (TypeError, ValueError):
679
- opacity = 1.0
680
- alpha = source_alpha * max(0.0, min(opacity, 1.0))
681
- effective[media] = min(effective.get(media, 255.0), alpha)
682
- return effective
683
-
684
-
685
- def fullscreen_overlay_media(data, outdir, shapes):
686
- """需要模型判断的满屏叠加层媒体。"""
687
- return {
688
- media for media, alpha in fullscreen_effective_alpha(data, outdir, shapes).items()
689
- if 13 <= alpha < OPAQUE_ENOUGH
690
- }
691
-
692
-
693
618
  def bg_busy_map(path, canvas, cells=12):
694
619
  """把背景图切成网格,报每格的**局部对比度**(该格内亮度极差)。
695
620
 
@@ -767,8 +692,7 @@ def copy_logo_candidates(outdir, logo_pool):
767
692
  return rows
768
693
 
769
694
 
770
- def draft_assets(d, outdir, bg_needed=(), cover_media=None, bg_under=None,
771
- effective_alpha=None):
695
+ def draft_assets(d, outdir, bg_needed=(), cover_media=None, bg_under=None):
772
696
  imgs = {i['media']: i for i in d['images']}
773
697
  cluster_of = {}
774
698
  for c in d.get('media_clusters', []):
@@ -785,35 +709,16 @@ def draft_assets(d, outdir, bg_needed=(), cover_media=None, bg_under=None,
785
709
  boxes = img.get('boxes') or []
786
710
  top = max(boxes, key=lambda b: b.get('count', 0)) if boxes else {}
787
711
  parts = top.get('parts') or []
788
- placements, seen_placements = [], set()
789
- for cluster in boxes:
790
- box = cluster.get('box') or {}
791
- rounded = [round(box.get(key, 0)) for key in ('x', 'y', 'w', 'h')]
792
- for part in cluster.get('parts') or []:
793
- if '/slides/' in part:
794
- row = {'slide': slide_no(part), 'box': rounded}
795
- elif '/slideLayouts/' in part:
796
- row = {'layout': os.path.basename(part), 'box': rounded}
797
- else:
798
- continue
799
- key = (row.get('slide'), row.get('layout'), tuple(rounded))
800
- if key not in seen_placements:
801
- seen_placements.add(key)
802
- placements.append(row)
803
- placements.sort(key=lambda row: (
804
- row.get('slide', 9999), row.get('layout', ''), tuple(row['box'])))
805
- slides = sorted({row['slide'] for row in placements if row.get('slide')})
712
+ slides = sorted({slide_no(p) for p in parts if '/slides/' in p})
806
713
  cands.append({
807
714
  'media': m['media'], 'file': os.path.basename(out_rel), 'out': out_rel,
808
715
  'bytes': m.get('bytes'), 'n': img.get('n', m.get('used_n', 0)),
809
716
  'has_compressed': bool(m.get('compressed_out')),
810
717
  'fullscreen': bool(img.get('fullscreen')), 'w_pct': img.get('max_w_pct', 0),
811
718
  'box': top.get('box') or {}, 'slides': slides,
812
- 'placements': placements,
813
719
  'layer_only': bool(parts) and not slides,
814
720
  'repeat': bool(img.get('repeat_fixed')),
815
721
  'cluster': cluster_of.get(m['media']),
816
- 'effective_alpha_mean': (effective_alpha or {}).get(m['media']),
817
722
  'probe': probe, 'reasons': m.get('reasons', []),
818
723
  })
819
724
 
@@ -840,18 +745,14 @@ def draft_assets(d, outdir, bg_needed=(), cover_media=None, bg_under=None,
840
745
  bg_i = 0
841
746
  canvas_w, canvas_h = d['canvas']['px']
842
747
  for c in kept:
843
- effective_am = c.get('effective_alpha_mean')
844
- if (c['fullscreen'] and (c['probe'].get('near_blank')
845
- or (effective_am is not None and effective_am < 13))):
846
- rejected.append((c, '近全透明(alpha 均值 %.0f/255),PPT 里看不见'
847
- % (effective_am if effective_am is not None
848
- else c['probe']['alpha_mean'])))
748
+ if c['probe'].get('near_blank'):
749
+ rejected.append((c, '近全透明(alpha 均值 %.0f/255),PPT 里看不见' % c['probe']['alpha_mean']))
849
750
  continue
850
751
  # 铺满 ≠ 能当背景。背景的定义性属性是**遮盖**:它得挡住底下的东西。一张大半透明
851
752
  # 的图铺满整页也遮不住任何像素,它在 PPT 里是叠在幻灯片底色上的一层装饰(顶部
852
753
  # 光晕之类),底色才是真背景。实测某模板一张 alpha 均值 30/255、72% 完全透明的
853
754
  # 顶部光晕被当成满屏背景收进包,消费端每页铺它,顶部就多出一条原稿没有的浓色带。
854
- am = effective_am if effective_am is not None else c['probe'].get('alpha_mean')
755
+ am = c['probe'].get('alpha_mean')
855
756
  if c['fullscreen'] and am is not None and am < OPAQUE_ENOUGH:
856
757
  rejected.append((c, 'alpha 均值只有 %.0f/255,遮不住底下的东西——'
857
758
  '它是叠在底色上的装饰层,不是背景' % am))
@@ -915,13 +816,13 @@ def draft_assets(d, outdir, bg_needed=(), cover_media=None, bg_under=None,
915
816
  for i, (corner, c) in enumerate(logo_pool):
916
817
  b = c['box']
917
818
  if i == 0:
918
- # 贴角、重复只能说明“像 logo”,不能替模型判定。比如一张产品功能角标也会
919
- # 同时满足这些结构特征;先作为候选保留在联系表与图片槽中,由模型定为 logo
920
- # 或 content,避免把内容图直接带进风格包。
921
- rejected.append((c, '贴角重复小图候选(%.0fx%.0f @ %.0f,%.0f,出现 %d 次,'
922
- '离画布边 %.0f%%),结合样张判断 logo 或 content'
923
- % (b.get('w', 0), b.get('h', 0), b.get('x', 0), b.get('y', 0),
924
- c['n'], corner * 50)))
819
+ assets.append({'id': 'logo-primary', 'kind': 'logo', 'role': None, 'src': c,
820
+ 'use_full': False, 'on_bg': on_bg_of(c)})
821
+ todos.append('看联系表确认 `%s` 真是品牌 logo(%.0fx%.0f @ %.0f,%.0f,出现 %d 次,'
822
+ '离画布边 %.0f%%,是所有小图里最贴角的一张);'
823
+ '不是就把 manifest 的 logo-primary 换成别的候选或整条删掉'
824
+ % (c['file'], b.get('w', 0), b.get('h', 0), b.get('x', 0), b.get('y', 0),
825
+ c['n'], corner * 50))
925
826
  else:
926
827
  rejected.append((c, '重复小图(%.0fx%.0f @ %.0f,%.0f),贴角程度 %.0f%% 不如首选'
927
828
  % (b.get('w', 0), b.get('h', 0), b.get('x', 0), b.get('y', 0),
@@ -953,41 +854,6 @@ def draft_assets(d, outdir, bg_needed=(), cover_media=None, bg_under=None,
953
854
  return assets, rejected, todos, alias, {c['media']: c for c in kept}
954
855
 
955
856
 
956
- def cover_background_media(archetypes):
957
- """只从模板明确命名的 cover 页型读取封面背景。"""
958
- return next((archetype['bg_raw'] for archetype in archetypes
959
- if archetype['name'] == 'cover'), None)
960
-
961
-
962
- def background_decor(background, canvas):
963
- """把可直接重放的 PPT 背景声明落到 layouts.md 的最底层装饰。
964
-
965
- 图片背景继续走 `background:` 资产引用;纯色和线性渐变没有可复制的素材文件,
966
- 但同样是页型视觉的一部分,必须随页型输出。path 渐变不能由 CSS 线性渐变准确表达,
967
- 保留给图片/渲染链路而不伪造。
968
- """
969
- if not isinstance(background, dict):
970
- return None
971
- if background.get('type') not in ('solid', 'gradient'):
972
- return None
973
- if background.get('path'):
974
- return None
975
- css = _load_query()._recipe_css(background, None, [], [])
976
- css = [re.sub(r'\s*\n\s*', ' ', value).strip() for value in css if value]
977
- if not css:
978
- return None
979
- width, height = canvas
980
- return {'box': [0, 0, width, height], 'geom': 'rect', 'css': '; '.join(css),
981
- 'trace': 'canvas-background'}
982
-
983
-
984
- def background_identity(background):
985
- """返回可用于聚类和审计的稳定背景标识,不改变背景的原始表达。"""
986
- if isinstance(background, dict):
987
- return json.dumps(background, sort_keys=True, separators=(',', ':'))
988
- return background
989
-
990
-
991
857
  # ---------------------------------------------------------------- 版式聚类
992
858
  DECOR_MIN = 40.0
993
859
 
@@ -1085,21 +951,19 @@ def slot_style(s):
1085
951
  不会写进消费者产物。
1086
952
  """
1087
953
  txt = s.get('text') or {}
1088
- inherited = dict((txt.get('lstStyle') or {}).get('lvl1pPr') or {})
1089
- ls = {}
954
+ ls = dict((txt.get('lstStyle') or {}).get('lvl1pPr') or {})
1090
955
  # 四层逐级兜底,按 OOXML 的就近原则:run rPr → 段落 defRPr → 段落 pPr → lstStyle。
1091
956
  # 只枚举前几层会整份漏掉——有的导出器把字号全写在 run rPr 上,lstStyle 一个都没有。
1092
957
  for para in (txt.get('paragraphs') or []):
1093
- srcs = [r for r in (para.get('runs') or [])]
958
+ srcs = [r.get('rPr') or {} for r in (para.get('runs') or [])]
1094
959
  srcs.append(para.get('defRPr') or {})
1095
960
  srcs.append({k: v for k, v in para.items() if k not in ('runs', 'defRPr')})
1096
961
  for src in srcs:
1097
962
  for k, v in (src or {}).items():
1098
963
  if v is not None:
1099
964
  ls.setdefault(k, v)
1100
- for k, v in inherited.items():
1101
- if v is not None:
1102
- ls.setdefault(k, v)
965
+ if ls.get('sz_px'):
966
+ break
1103
967
  if not ls.get('sz_px'):
1104
968
  # 仍无声明:退到整形状里出现过的最大字号(generic walk),仍是文件里的值
1105
969
  anysz = shape_sz(s)
@@ -1118,16 +982,9 @@ def slot_style(s):
1118
982
  css_number(insets.get('lIns', 0) or 0),
1119
983
  ))
1120
984
  if ls.get('sz_px'):
1121
- # normAutofit 的 fontScale 是模板让大字装进小框的手段——不乘它,消费端拿到的是
1122
- # 未缩放字号,字比框高,渐变裁切会把溢出的底部切成透明。缺省 1.0(无 autofit / 无缩放)。
1123
- scale = body.get('font_scale')
1124
- raw = ls['sz_px'] * scale if scale else ls['sz_px']
1125
- size = round(raw)
985
+ size = round(ls['sz_px'])
1126
986
  css.append('font-size: %dpx' % size)
1127
987
  out['_font_size'] = size
1128
- typeface = ls.get('ea') or ls.get('latin') or ls.get('cs')
1129
- if typeface:
1130
- css.append('font-family: %s' % font_css([typeface]))
1131
988
  weight = ls.get('weight') or (700 if ls.get('bold') else None)
1132
989
  if weight:
1133
990
  css.append('font-weight: %s' % weight)
@@ -1163,13 +1020,10 @@ def slot_style(s):
1163
1020
  'l': 'left', 'ctr': 'center', 'r': 'right', 'just': 'justify',
1164
1021
  }.get(align, align))
1165
1022
  line_spacing = ls.get('lnSpc') or {}
1166
- # normAutofit 的 lnSpcReduction 与 fontScale 同时把行距压缩,一起缩才装得进原框。
1167
- reduction = body.get('ln_spc_reduction') or 0
1168
1023
  if line_spacing.get('mult'):
1169
- mult = line_spacing['mult'] * 1.2 * (1 - reduction)
1170
- css.append('line-height: %s' % css_number(mult))
1024
+ css.append('line-height: %s' % css_number(line_spacing['mult'] * 1.2))
1171
1025
  elif line_spacing.get('px'):
1172
- css.append('line-height: %spx' % css_number(line_spacing['px'] * (1 - reduction)))
1026
+ css.append('line-height: %spx' % css_number(line_spacing['px']))
1173
1027
  anchor = body.get('anchor')
1174
1028
  if anchor in ('ctr', 'b'):
1175
1029
  css += ['display: flex', 'flex-direction: column',
@@ -1240,9 +1094,6 @@ def layouts_from_template(d, shapes, cW, cH):
1240
1094
  lay_of_slide = (d.get('reference_graph') or {}).get('layout_of_slide') or {}
1241
1095
  used_n = Counter(lay_of_slide.values())
1242
1096
  slide_of_layout = {lp: sp for sp, lp in lay_of_slide.items() if used_n[lp] == 1}
1243
- sample_pages_of_layout = defaultdict(list)
1244
- for slide_part, layout_part in lay_of_slide.items():
1245
- sample_pages_of_layout[layout_part].append(slide_no(slide_part))
1246
1097
  default_theme = topo.get('default')
1247
1098
  multi = len(topo.get('themes') or []) > 1
1248
1099
 
@@ -1255,7 +1106,7 @@ def layouts_from_template(d, shapes, cW, cH):
1255
1106
  phs.sort(key=lambda s: ((s['box'].get('y') or 0), (s['box'].get('x') or 0)))
1256
1107
  slots, seen_kind = [], set()
1257
1108
  for s in phs:
1258
- t = PH_TO_TYPE.get((s.get('ph') or {}).get('type'), 'body')
1109
+ t = PH_TO_TYPE.get((s['ph'] or {}).get('type'), 'body')
1259
1110
  if t in ('slide-number', 'footer') and not shape_text(s):
1260
1111
  continue # 空 chrome 占位符不是实际元素
1261
1112
  b = s['box']
@@ -1299,17 +1150,7 @@ def layouts_from_template(d, shapes, cW, cH):
1299
1150
  slots, bgm = instance_override(
1300
1151
  shapes, inst, slots, bgm, cW, cH, composites)
1301
1152
  taken = {tuple(s['box']) for s in slots}
1302
- decor = []
1303
- inherited_background = l.get('background')
1304
- direct_background = next(
1305
- (slide.get('background') for slide in d.get('slides') or []
1306
- if slide.get('part') == inst and slide.get('background')),
1307
- None)
1308
- background = direct_background or inherited_background
1309
- background_layer = background_decor(background, (cW, cH))
1310
- if background_layer:
1311
- decor.append(background_layer)
1312
- decor += collect_decor(shapes, inst or l['part'], taken, (cW, cH))
1153
+ decor = collect_decor(shapes, inst or l['part'], taken, (cW, cH))
1313
1154
  named_role = role_of_name(l.get('name'))
1314
1155
  rows.append({'zh': clean_layout_name(l.get('name')),
1315
1156
  'role': named_role or 'content', 'role_guessed': named_role is None,
@@ -1345,8 +1186,6 @@ def layouts_from_template(d, shapes, cW, cH):
1345
1186
  # 版式名认不出 role 时不装作有把握:置信度降到 low,让 L 层看图定
1346
1187
  'pic_n': 0, 'confidence': 'low' if r.get('role_guessed') else 'high',
1347
1188
  'theme': r['theme'] if multi else None,
1348
- '_layout_part': r['part'],
1349
- '_sample_pages': sorted(sample_pages_of_layout.get(r['part']) or []),
1350
1189
  'source': 'layout:' + r['part'].split('/')[-1]})
1351
1190
  return arch
1352
1191
 
@@ -1462,79 +1301,13 @@ def inherited_text_shapes(layout_shapes, slide_shapes):
1462
1301
  return out
1463
1302
 
1464
1303
 
1465
- def slide_image_marks(data, included_fullscreen=()):
1466
- """从图片普查补齐形状图片填充;它们没有独立 pic 节点,但仍有媒体与坐标。"""
1467
- allowed_fullscreen = set(included_fullscreen)
1468
- out = defaultdict(list)
1469
- for image in data.get('images') or []:
1470
- media = image.get('media')
1471
- if not media or (image.get('fullscreen') and media not in allowed_fullscreen):
1472
- continue
1473
- for cluster in image.get('boxes') or []:
1474
- box = cluster.get('box')
1475
- if not box or not box.get('w'):
1476
- continue
1477
- for part in cluster.get('parts') or []:
1478
- if '/slides/' not in part and '/slideLayouts/' not in part:
1479
- continue
1480
- out[part].append({'media': media, 'box': box})
1481
- return out
1482
-
1483
-
1484
- def add_template_image_marks(archetypes, data, included_fullscreen=()):
1485
- """把版式和实例页的图片填充补进 form=3 页型。"""
1486
- marks_by_part = slide_image_marks(data, included_fullscreen)
1487
- layout_of_slide = (data.get('reference_graph') or {}).get('layout_of_slide') or {}
1488
- by_layout = {archetype.get('_layout_part'): archetype for archetype in archetypes}
1489
- for part, marks in marks_by_part.items():
1490
- layout_part = layout_of_slide.get(part, part)
1491
- archetype = by_layout.get(layout_part)
1492
- if not archetype:
1493
- continue
1494
- seen = {
1495
- (slot.get('media'), tuple(slot.get('box') or ()))
1496
- for slot in archetype.get('slots') or []
1497
- if slot.get('media')
1498
- }
1499
- for mark in marks:
1500
- box = mark['box']
1501
- rounded = [round(box.get(key, 0)) for key in ('x', 'y', 'w', 'h')]
1502
- key = (mark['media'], tuple(rounded))
1503
- if key in seen:
1504
- continue
1505
- seen.add(key)
1506
- archetype['slots'].append({
1507
- 'role': 'logo',
1508
- 'type': 'pic',
1509
- 'sz': 0,
1510
- 'txt': '',
1511
- 'media': mark['media'],
1512
- 'box': rounded,
1513
- })
1514
-
1515
-
1516
- def preserve_image_bearing_groups(kept, ranked):
1517
- """有图片实例的孤例保留自己的页型,避免把资产绑定到近似但错误的版式。"""
1518
- return kept + [
1519
- group for group in ranked
1520
- if group not in kept and any(page.get('marks') for page in group[1])
1521
- ]
1522
-
1523
-
1524
- def draft_layouts(d, outdir, effective_alpha=None):
1304
+ def draft_layouts(d, outdir):
1525
1305
  with open(os.path.join(outdir, 'ref', 'shapes.json'), encoding='utf-8') as stream:
1526
1306
  shapes = json.load(stream)['shapes']
1527
1307
  cW, cH = d['canvas']['px']
1528
- if effective_alpha is None:
1529
- effective_alpha = fullscreen_effective_alpha(d, outdir, shapes)
1530
- overlay_media = {
1531
- media for media, alpha in effective_alpha.items()
1532
- if 13 <= alpha < OPAQUE_ENOUGH
1533
- }
1534
1308
  if (d.get('form_hint') or {}).get('form') == 3:
1535
1309
  arch = layouts_from_template(d, shapes, cW, cH)
1536
1310
  if len(arch) >= 3:
1537
- add_template_image_marks(arch, d, overlay_media)
1538
1311
  return arch, [], []
1539
1312
  by_slide = defaultdict(list)
1540
1313
  by_layout = defaultdict(list)
@@ -1543,22 +1316,18 @@ def draft_layouts(d, outdir, effective_alpha=None):
1543
1316
  by_slide[s['part']].append(s)
1544
1317
  elif s.get('layer') == 'layout':
1545
1318
  by_layout[s['part']].append(s)
1546
- image_marks = slide_image_marks(d, overlay_media)
1547
1319
 
1548
1320
  bg_of_slide, layout_of_slide = {}, {}
1549
- background_of_layout = {
1550
- row['part']: row.get('background') for row in d.get('layouts') or []
1551
- }
1552
1321
  for s in d.get('slides', []):
1553
1322
  bg = s.get('background')
1554
- bg_of_slide[s['part']] = background_identity(bg)
1323
+ bg_of_slide[s['part']] = json.dumps(bg, sort_keys=True) if isinstance(bg, dict) else bg
1555
1324
  layout_of_slide[s['part']] = s.get('layout')
1556
1325
  # 版式层的满屏底图(form=2 常态:底图挂在 layout 上)
1557
1326
  composites = d.get('background_composites') or {}
1558
- bg_media_of_layout = {}
1327
+ bg_of_layout = {}
1559
1328
  for s in shapes:
1560
1329
  if s.get('layer') == 'layout' and is_bleed(s) and s.get('media'):
1561
- bg_media_of_layout[s['part']] = s['media']
1330
+ bg_of_layout[s['part']] = s['media']
1562
1331
 
1563
1332
  pages = []
1564
1333
  for part, sh in sorted(by_slide.items(), key=lambda kv: slide_no(kv[0])):
@@ -1566,7 +1335,7 @@ def draft_layouts(d, outdir, effective_alpha=None):
1566
1335
  layout_shapes = by_layout.get(layout_part) or []
1567
1336
  bg_media = top_bleed_media(sh)
1568
1337
  if bg_media is None:
1569
- bg_media = bg_media_of_layout.get(layout_part)
1338
+ bg_media = bg_of_layout.get(layout_part)
1570
1339
  rendered_bg = (composites.get(part)
1571
1340
  or composites.get(layout_part)
1572
1341
  or bg_media)
@@ -1591,32 +1360,13 @@ def draft_layouts(d, outdir, effective_alpha=None):
1591
1360
  })
1592
1361
  texts.sort(key=lambda t: (-t['sz'], t['box'].get('y', 0)))
1593
1362
  visible_shapes = layout_shapes + sh
1594
- pics = []
1595
- for shape in visible_shapes:
1596
- if shape.get('kind') != 'pic':
1597
- continue
1598
- if shape.get('w_pct', 0) < 95 or shape.get('media') in overlay_media:
1599
- pics.append(shape)
1363
+ pics = [s for s in visible_shapes if s.get('kind') == 'pic' and s.get('w_pct', 0) < 95]
1600
1364
  # 小图元素(logo / 角标 / 装饰)逐页记位置,供 archetype 落 slots
1601
1365
  marks = [{'media': s['media'], 'box': s['box']} for s in pics
1602
- if s.get('media') and (s.get('box') or {}).get('w')]
1603
- seen_marks = {
1604
- (mark['media'], round(mark['box'].get('x', 0)), round(mark['box'].get('y', 0)))
1605
- for mark in marks
1606
- }
1607
- for mark in image_marks.get(part) or []:
1608
- key = (mark['media'], round(mark['box'].get('x', 0)),
1609
- round(mark['box'].get('y', 0)))
1610
- if key not in seen_marks:
1611
- seen_marks.add(key)
1612
- marks.append(mark)
1613
- background = next((s.get('background') for s in d.get('slides') or []
1614
- if s.get('part') == part and s.get('background')), None)
1615
- background = background or background_of_layout.get(layout_part)
1366
+ if s.get('media') and (s.get('box') or {}).get('w') and s.get('w_pct', 0) < 30]
1616
1367
  pages.append({'part': part, 'no': slide_no(part), 'bg_media': bg_media,
1617
1368
  'rendered_bg': rendered_bg,
1618
- 'bg_color': bg_of_slide.get(part), 'background': background,
1619
- 'texts': texts, 'pic_n': len(pics),
1369
+ 'bg_color': bg_of_slide.get(part), 'texts': texts, 'pic_n': len(pics),
1620
1370
  'marks': marks, 'shape_n': len(visible_shapes), 'layout': layout_part})
1621
1371
 
1622
1372
  # 页型的**角色**(封面 / 章节页 / 内容页……)不在这里判:那是看图才能下的结论,
@@ -1629,7 +1379,6 @@ def draft_layouts(d, outdir, effective_alpha=None):
1629
1379
  n = len(p['texts'])
1630
1380
  return 0 if n <= q1 else (1 if n <= q2 else 2)
1631
1381
 
1632
- last_page_no = max((p['no'] for p in pages), default=None)
1633
1382
  groups = defaultdict(list)
1634
1383
  for p in pages:
1635
1384
  if p['no'] == 1:
@@ -1637,40 +1386,28 @@ def draft_layouts(d, outdir, effective_alpha=None):
1637
1386
  # 并进别的组就会被代表页顶掉、坐标全丢。这只是不合并,不代表它是封面。
1638
1387
  groups[('__first__', -1)] = [p]
1639
1388
  continue
1640
- if p['no'] == last_page_no:
1641
- # 末页也单独保留完整结构:它可能是封底,也可能只是最后一张内容页,脚本
1642
- # 不替模型下结论。和首页一样,拆组只避免它被聚类代表页吞掉。
1643
- groups[('__last__', -2)] = [p]
1644
- continue
1645
- background_key = background_identity(
1646
- p.get('rendered_bg') or p.get('bg_media')
1647
- or p.get('background') or p.get('bg_color')
1648
- ) or 'none'
1649
- groups[(background_key, density_band(p))].append(p)
1389
+ groups[(p['bg_media'] or p['bg_color'] or 'none', density_band(p))].append(p)
1650
1390
 
1651
1391
  ranked = sorted(groups.items(), key=lambda kv: (-len(kv[1]), kv[1][0]['no']))
1652
1392
  # 首页所在的组一定收——deck 的第一页是模板的门面,孤例也不能被名额挤掉。
1653
1393
  # 这只保证它进包,它是不是封面由看图的人定。
1654
1394
  first = [g for g in ranked if g[0][0] == '__first__']
1655
- last = [g for g in ranked if g[0][0] == '__last__']
1656
- kept = first + last + [
1657
- g for g in ranked
1658
- if g not in first and g not in last and len(g[1]) >= 2
1659
- ][:max(0, 8 - len(first) - len(last))]
1395
+ kept = first + [g for g in ranked if g not in first and len(g[1]) >= 2][:8 - len(first)]
1660
1396
  for g in ranked: # 名额没用满就把最大的孤例页也收进来
1661
1397
  if len(kept) >= 8:
1662
1398
  break
1663
1399
  if g not in kept:
1664
1400
  kept.append(g)
1665
- # 图片用途必须与它实际所在的版式绑定。若把图片孤例并到“最接近”页型,装饰会被
1666
- # 绑定到错误布局;是否为内容图、logo 墙或装饰由后续模型看图判断,不按图片数量猜。
1667
- kept = preserve_image_bearing_groups(kept, ranked)
1668
1401
  leftover = sorted(p['no'] for g in ranked if g not in kept for p in g[1])
1669
1402
 
1670
1403
  archetypes = []
1671
- for gi, ((_background_key, _band), ps) in enumerate(kept, 1):
1404
+ for gi, ((bg_raw, _band), ps) in enumerate(kept, 1):
1672
1405
  rep = max(ps, key=lambda p: len(p['texts']))
1673
- bg_raw = rep.get('rendered_bg') or rep.get('bg_media')
1406
+ if bg_raw == '__first__':
1407
+ bg_raw = rep['bg_media'] or rep['bg_color'] or 'none'
1408
+ rendered_bg = rep.get('rendered_bg')
1409
+ if rendered_bg:
1410
+ bg_raw = rendered_bg
1674
1411
  name = 'layout-%d' % gi
1675
1412
  # 标题按「位置 + 跨度」认,不按字号——big-number 类的巨号数值常比标题还大
1676
1413
  # 标题 = 该页最靠上的那批文本里最宽的一块。不按「画布前 28%」这类固定比例切:
@@ -1713,25 +1450,20 @@ def draft_layouts(d, outdir, effective_alpha=None):
1713
1450
  '_placeholder': t.get('placeholder'),
1714
1451
  })
1715
1452
  slots.append(row)
1716
- # 同组页面上的图片元素按素材+位置去重后落候选 slots。内容图去掉具体资产引用,
1717
- # 保留通用图片槽;装饰图绑定资产,避免非代表页上的装饰没有进入 layouts。
1453
+ # 代表页上的小图元素按位置去重后落 slots(同一 logo 在不同页型位置不同)
1718
1454
  seen_mark = set()
1719
- for page in ps:
1720
- for mk in page.get('marks') or []:
1721
- b = mk['box']
1722
- key = (mk['media'], round(b.get('x', 0)), round(b.get('y', 0)))
1723
- if key in seen_mark:
1724
- continue
1725
- seen_mark.add(key)
1726
- slots.append({'role': 'logo', 'type': 'pic', 'sz': 0, 'txt': '',
1727
- 'media': mk['media'],
1728
- 'box': [round(b.get('x', 0)), round(b.get('y', 0)),
1729
- round(b.get('w', 0)), round(b.get('h', 0))]})
1455
+ for mk in rep.get('marks') or []:
1456
+ b = mk['box']
1457
+ key = (mk['media'], round(b.get('x', 0)), round(b.get('y', 0)))
1458
+ if key in seen_mark:
1459
+ continue
1460
+ seen_mark.add(key)
1461
+ slots.append({'role': 'logo', 'type': 'pic', 'sz': 0, 'txt': '',
1462
+ 'media': mk['media'],
1463
+ 'box': [round(b.get('x', 0)), round(b.get('y', 0)),
1464
+ round(b.get('w', 0)), round(b.get('h', 0))]})
1730
1465
  taken = {tuple(s['box']) for s in slots}
1731
1466
  decor = []
1732
- background_layer = background_decor(rep.get('background'), (cW, cH))
1733
- if background_layer:
1734
- decor.append(background_layer)
1735
1467
  seen_decor = set()
1736
1468
  for source_part in (rep.get('layout'), rep['part']):
1737
1469
  for item in collect_decor(shapes, source_part, taken, (cW, cH)):
@@ -1743,20 +1475,6 @@ def draft_layouts(d, outdir, effective_alpha=None):
1743
1475
  'decor': decor,
1744
1476
  'pages': sorted(p['no'] for p in ps), 'rep': rep['no'],
1745
1477
  'pic_n': rep['pic_n'],
1746
- '_source_layouts': sorted({
1747
- p['layout'] for p in ps if p.get('layout')
1748
- }),
1749
- '_source_backgrounds': sorted({
1750
- background_identity(
1751
- p.get('rendered_bg') or p.get('bg_media')
1752
- or p.get('background') or p.get('bg_color')
1753
- )
1754
- for p in ps
1755
- if (p.get('rendered_bg') or p.get('bg_media')
1756
- or p.get('background') or p.get('bg_color'))
1757
- }),
1758
- '_text_n': len(rep['texts']),
1759
- '_last_page_candidate': rep['no'] == last_page_no,
1760
1478
  'confidence': 'high' if len(ps) >= 3 else
1761
1479
  ('medium' if len(ps) == 2 else 'low')})
1762
1480
  return archetypes, pages, leftover
@@ -1770,19 +1488,13 @@ def layout_sheet(outdir, archetypes, path):
1770
1488
  reps = [x for x in reps if x is not None]
1771
1489
  if not reps:
1772
1490
  return None
1491
+ import subprocess
1492
+ r = subprocess.run([sys.executable, os.path.join(HERE, 'render_pages.py'), outdir,
1493
+ '--pages', 'layouts' if use_layout else 'slides',
1494
+ '--only', ','.join(map(str, reps)), '--no-html'],
1495
+ capture_output=True, text=True)
1773
1496
  png_dir = os.path.join(outdir, 'ref', 'rebuild', 'png')
1774
- kind = 'layout' if use_layout else 'slide'
1775
- missing = [no for no in reps
1776
- if not os.path.exists(os.path.join(png_dir, '%s-%s.png' % (kind, no)))]
1777
- if missing:
1778
- import subprocess
1779
- r = subprocess.run([sys.executable, os.path.join(HERE, 'render_pages.py'), outdir,
1780
- '--pages', 'layouts' if use_layout else 'slides',
1781
- '--only', ','.join(map(str, missing)), '--no-html'],
1782
- capture_output=True, text=True)
1783
- if r.returncode:
1784
- return None
1785
- if not os.path.isdir(png_dir):
1497
+ if r.returncode or not os.path.isdir(png_dir):
1786
1498
  return None
1787
1499
  try:
1788
1500
  from PIL import Image, ImageDraw
@@ -1798,7 +1510,7 @@ def layout_sheet(outdir, archetypes, path):
1798
1510
  x = pad + (i % cols) * (cw + pad)
1799
1511
  y = pad + (i // cols) * (ch + pad + lab)
1800
1512
  no = a.get('rep_layout') if use_layout else a.get('rep')
1801
- f = os.path.join(png_dir, '%s-%s.png' % (kind, no))
1513
+ f = os.path.join(png_dir, '%s-%s.png' % ('layout' if use_layout else 'slide', no))
1802
1514
  if os.path.exists(f):
1803
1515
  im = Image.open(f).convert('RGB')
1804
1516
  im.thumbnail((cw, ch))
@@ -1815,7 +1527,7 @@ def layout_sheet(outdir, archetypes, path):
1815
1527
  return path
1816
1528
 
1817
1529
 
1818
- def contact_sheet(outdir, cands, path, start_index=1):
1530
+ def contact_sheet(outdir, cands, path):
1819
1531
  try:
1820
1532
  from PIL import Image, ImageDraw
1821
1533
  except Exception:
@@ -1845,548 +1557,19 @@ def contact_sheet(outdir, cands, path, start_index=1):
1845
1557
  dr.text((x + 8, y + 8), 'unreadable', fill=(200, 0, 0))
1846
1558
  dr.rectangle([x, y, x + cell, y + cell], outline=(120, 120, 128))
1847
1559
  dr.text((x + 2, y + cell + 4), '[%d] %s %dx%d used=%d'
1848
- % (c.get('_candidate_index', start_index + idx), c['file'],
1849
- c['probe'].get('w') or 0,
1850
- c['probe'].get('h') or 0, c['n']),
1560
+ % (idx + 1, c['file'], c['probe'].get('w') or 0, c['probe'].get('h') or 0, c['n']),
1851
1561
  fill=(20, 20, 24))
1852
1562
  sheet.save(path, optimize=True)
1853
1563
  return path
1854
1564
 
1855
1565
 
1856
- def contact_sheets(outdir, cands, ldir):
1857
- paths = []
1858
- legacy = os.path.join(ldir, 'contact-sheet.png')
1859
- if os.path.exists(legacy):
1860
- os.remove(legacy)
1861
- for start in range(0, len(cands), SHEET_BATCH):
1862
- batch = cands[start:start + SHEET_BATCH]
1863
- path = os.path.join(ldir, 'contact-sheet-%d.png' % (start // SHEET_BATCH + 1))
1864
- if contact_sheet(outdir, batch, path, start + 1):
1865
- paths.append(path)
1866
- if paths:
1867
- shutil.copy2(paths[0], legacy)
1868
- return paths
1869
-
1870
-
1871
- def asset_vision_contexts(candidates):
1872
- """把同一素材的每个归纳页型实例放进对应语境,不只展示最早出现的页面。"""
1873
- contexts = []
1874
- for candidate in candidates:
1875
- placements = candidate.get('placements') or []
1876
- instance_placements = [row for row in placements if row.get('slide')]
1877
- if not instance_placements:
1878
- row = dict(candidate)
1879
- row['source_placements'] = placements
1880
- contexts.append(row)
1881
- continue
1882
- seen = set()
1883
- id_counts = Counter()
1884
- for placement in instance_placements:
1885
- box = tuple(placement['box'])
1886
- layout = placement.get('archetype')
1887
- key = (layout, box) if layout else (placement['slide'], box)
1888
- if key in seen:
1889
- continue
1890
- seen.add(key)
1891
- row = dict(candidate)
1892
- base_id = '%s-s%d' % (candidate['id'], placement['slide'])
1893
- id_counts[base_id] += 1
1894
- row['id'] = (base_id if id_counts[base_id] == 1
1895
- else '%s-%d' % (base_id, id_counts[base_id]))
1896
- row['placements'] = [placement]
1897
- row['slides'] = [placement['slide']]
1898
- row['layout'] = layout
1899
- row['source_placements'] = placements
1900
- contexts.append(row)
1901
- return contexts
1902
-
1903
-
1904
- def _group_input_count(group):
1905
- # 与 FaaS 一致:每页桶预留一张页面语境图;无实例页的版式候选也占一个图位。
1906
- page_count = len([page for page in group['pages'] if page > 0]) or 1
1907
- return len(group['candidates']) + page_count
1908
-
1909
-
1910
- def build_asset_vision_groups(candidates):
1911
- """按 FaaS 的 10/5 图数预算,把候选按所在页组合成视觉判断批次。"""
1912
- buckets = defaultdict(list)
1913
- for candidate in asset_vision_contexts(candidates):
1914
- page = next((row['slide'] for row in candidate.get('placements') or []
1915
- if row.get('slide')), 0)
1916
- buckets[page].append(candidate)
1917
-
1918
- groups, queued = [], []
1919
-
1920
- def flush_small_pages():
1921
- if not queued:
1922
- return
1923
- current, current_pages, inputs = [], [], 0
1924
- for page, page_candidates in queued:
1925
- page_inputs = 1 + len(page_candidates)
1926
- if current and inputs + page_inputs > MULTI_PAGE_IMAGE_BUDGET:
1927
- groups.append({'pages': current_pages, 'candidates': current})
1928
- current, current_pages, inputs = [], [], 0
1929
- current.extend(page_candidates)
1930
- current_pages.append(page)
1931
- inputs += page_inputs
1932
- if current:
1933
- groups.append({'pages': current_pages, 'candidates': current})
1934
- del queued[:]
1935
-
1936
- for page in sorted(buckets):
1937
- page_candidates = buckets[page]
1938
- page_inputs = 1 + len(page_candidates)
1939
- if page_inputs > MULTI_PAGE_IMAGE_BUDGET:
1940
- flush_small_pages()
1941
- per_group = SINGLE_PAGE_IMAGE_BUDGET - 1
1942
- for start in range(0, len(page_candidates), per_group):
1943
- groups.append({
1944
- 'pages': [page],
1945
- 'candidates': page_candidates[start:start + per_group],
1946
- })
1947
- continue
1948
- queued.append((page, page_candidates))
1949
- flush_small_pages()
1950
-
1951
- for index, group in enumerate(groups, 1):
1952
- group['id'] = 'vision-%d' % index
1953
- group['input_count'] = _group_input_count(group)
1954
- return groups
1955
-
1956
-
1957
- def select_asset_vision_groups(groups, slide_count):
1958
- """受总预算约束选择视觉批次:首页和尾页的所有可容纳分批优先于中间页。"""
1959
- if not groups:
1960
- return [], []
1961
- first_page = 1
1962
- last_page = slide_count or max(
1963
- (page for group in groups for page in group['pages'] if page > 0), default=0)
1964
- selected, selected_ids, inputs = [], set(), 0
1965
-
1966
- def add(group):
1967
- nonlocal inputs
1968
- if (group['id'] in selected_ids or len(selected) >= VISUAL_PACK_CAP
1969
- or inputs + group['input_count'] > VISUAL_INPUT_CAP):
1970
- return False
1971
- selected.append(group)
1972
- selected_ids.add(group['id'])
1973
- inputs += group['input_count']
1974
- return True
1975
-
1976
- # 首尾页先于中间页保留全部可容纳分批。交错加入避免首页多批先占满总预算,尾页
1977
- # 连首批都进不去;单页 deck 不重复扫描。
1978
- priority_batches = [
1979
- [group for group in groups if page in group['pages']]
1980
- for page in dict.fromkeys((first_page, last_page))
1981
- ]
1982
- for batch_index in range(max(map(len, priority_batches), default=0)):
1983
- for batches in priority_batches:
1984
- if batch_index < len(batches):
1985
- add(batches[batch_index])
1986
-
1987
- # 首尾的第一个批次已经保证;剩余按页码保留前段内容,优先丢弃尾页之前的后段。
1988
- remainder = sorted(
1989
- (group for group in groups if group['id'] not in selected_ids),
1990
- key=lambda group: (
1991
- min((page for page in group['pages'] if page > 0), default=999999),
1992
- group['id'],
1993
- ),
1994
- )
1995
- for group in remainder:
1996
- add(group)
1997
-
1998
- selected.sort(key=lambda group: (
1999
- min((page for page in group['pages'] if page > 0), default=999999), group['id']))
2000
- omitted = [group for group in groups if group['id'] not in selected_ids]
2001
- return selected, omitted
2002
-
2003
-
2004
- def _safe_remove(pattern):
2005
- for path in glob.glob(pattern):
2006
- try:
2007
- os.remove(path)
2008
- except OSError:
2009
- pass
2010
-
2011
-
2012
- def _fit_image(image, width, height):
2013
- copy = image.copy()
2014
- copy.thumbnail((width, height))
2015
- return copy
2016
-
2017
-
2018
- def _draw_checkerboard(draw, box, size=14):
2019
- x, y, w, h = box
2020
- for row in range(0, h, size):
2021
- for col in range(0, w, size):
2022
- if (row // size + col // size) % 2 == 0:
2023
- draw.rectangle([x + col, y + row, x + col + size - 1, y + row + size - 1],
2024
- fill=(214, 214, 218))
2025
-
2026
-
2027
- def _candidate_is_visual_risk(candidate):
2028
- probe = candidate.get('probe') or {}
2029
- alpha = candidate.get('effective_alpha_mean')
2030
- if alpha is None:
2031
- alpha = probe.get('alpha_mean')
2032
- return ((alpha is not None and alpha < 230)
2033
- or (probe.get('near_white_ratio') or 0) >= 0.7)
2034
-
2035
-
2036
- def _paste_candidate_preview(sheet, draw, image, box, dark=False):
2037
- x, y, w, h = box
2038
- if dark:
2039
- draw.rectangle([x, y, x + w, y + h], fill=(54, 54, 58))
2040
- else:
2041
- _draw_checkerboard(draw, box)
2042
- preview = _fit_image(image.convert('RGBA'), w - 8, h - 8)
2043
- px = x + (w - preview.width) // 2
2044
- py = y + (h - preview.height) // 2
2045
- sheet.paste(preview, (px, py), preview)
2046
-
2047
-
2048
- def _placement_text(candidate):
2049
- rows = []
2050
- for placement in candidate.get('placements') or []:
2051
- box = placement['box']
2052
- if placement.get('slide'):
2053
- rows.append('s%d@%d,%d,%d,%d' % (
2054
- placement['slide'], box[0], box[1], box[2], box[3]))
2055
- else:
2056
- rows.append('%s@%d,%d,%d,%d' % (
2057
- placement.get('layout') or 'layout', box[0], box[1], box[2], box[3]))
2058
- return ';'.join(rows)
2059
-
2060
-
2061
- def _save_visual_sheet(sheet, path):
2062
- if max(sheet.size) > VISUAL_PREVIEW_MAX_EDGE:
2063
- ratio = VISUAL_PREVIEW_MAX_EDGE / float(max(sheet.size))
2064
- sheet = sheet.resize((max(1, round(sheet.width * ratio)),
2065
- max(1, round(sheet.height * ratio))))
2066
- sheet.save(path, 'JPEG', quality=VISUAL_JPEG_QUALITY, optimize=True, progressive=True)
2067
-
2068
-
2069
- def render_asset_vision_pages(outdir, pages):
2070
- """视觉判断必须有页面语境;截图失败时中止草案而非让模型盲判。"""
2071
- if not pages:
2072
- return None
2073
- result = subprocess.run(
2074
- [sys.executable, os.path.join(HERE, 'render_pages.py'), outdir,
2075
- '--pages', 'slides', '--only', ','.join(map(str, pages)), '--no-html'],
2076
- capture_output=True, text=True,
2077
- )
2078
- png_dir = os.path.join(outdir, 'ref', 'rebuild', 'png')
2079
- missing = [
2080
- page for page in pages
2081
- if not os.path.isfile(os.path.join(png_dir, 'slide-%d.png' % page))
2082
- ]
2083
- if result.returncode or missing:
2084
- detail = (result.stderr or result.stdout or '').strip().splitlines()
2085
- raise RuntimeError(
2086
- '视觉判断所需页面截图生成失败%s%s' % (
2087
- '(缺第%s页)' % '、'.join(map(str, missing)) if missing else '',
2088
- ':' + detail[-1] if detail else '',
2089
- )
2090
- )
2091
- return png_dir
2092
-
2093
-
2094
- class VisionContextError(RuntimeError):
2095
- pass
2096
-
2097
-
2098
- def has_pillow():
2099
- try:
2100
- from PIL import Image # noqa: F401
2101
- except Exception:
2102
- return False
2103
- return True
2104
-
2105
-
2106
- def asset_vision_group_sheet(outdir, group, png_dir, path):
2107
- """把一组整页语境和候选图做成可索引拼版;每张候选保持独立卡片。"""
2108
- try:
2109
- from PIL import Image, ImageDraw
2110
- except Exception:
2111
- return None
2112
-
2113
- candidates = group['candidates']
2114
- page_count = len([page for page in group['pages'] if page > 0])
2115
- page_w, page_h = 440, 248
2116
- cell, pad, label_h = 220, 16, 42
2117
- asset_cols = 3
2118
- asset_rows = max(1, (len(candidates) + asset_cols - 1) // asset_cols)
2119
- page_rows = max(1, (page_count + 1) // 2) if page_count else 0
2120
- page_cols = min(2, page_count) if page_count else 0
2121
- width = max(2 * (page_w + pad) + pad if page_cols else 0,
2122
- asset_cols * (cell + pad) + pad)
2123
- header_h = 28
2124
- page_area_h = (page_rows * (page_h + label_h + pad) + pad) if page_rows else 0
2125
- asset_top = header_h + page_area_h
2126
- height = asset_top + asset_rows * (cell + label_h + pad) + pad
2127
- sheet = Image.new('RGB', (width, height), (245, 245, 247))
2128
- draw = ImageDraw.Draw(sheet)
2129
- draw.text((pad, 7), '%s inputs=%d pages=%s' % (
2130
- group['id'], group['input_count'],
2131
- ','.join(map(str, group['pages'])) or 'layout'), fill=(20, 20, 24))
2132
-
2133
- for index, page in enumerate([page for page in group['pages'] if page > 0]):
2134
- x = pad + (index % 2) * (page_w + pad)
2135
- y = header_h + (index // 2) * (page_h + label_h + pad)
2136
- source = (os.path.join(png_dir, 'slide-%d.png' % page)
2137
- if png_dir else None)
2138
- if source and os.path.exists(source):
2139
- try:
2140
- image = Image.open(source).convert('RGB')
2141
- image = _fit_image(image, page_w, page_h)
2142
- sheet.paste(image, (x + (page_w - image.width) // 2,
2143
- y + (page_h - image.height) // 2))
2144
- except Exception as exc:
2145
- raise VisionContextError('视觉判断所需页面截图不可读取:第%d页' % page) from exc
2146
- else:
2147
- raise VisionContextError('视觉判断所需页面截图缺失:第%d页' % page)
2148
- draw.rectangle([x, y, x + page_w, y + page_h], outline=(120, 120, 128))
2149
- draw.text((x + 2, y + page_h + 5), '[page %d] context for candidates below' % page,
2150
- fill=(20, 20, 24))
2151
-
2152
- for index, candidate in enumerate(candidates):
2153
- x = pad + (index % asset_cols) * (cell + pad)
2154
- y = asset_top + (index // asset_cols) * (cell + label_h + pad)
2155
- image_path = os.path.join(outdir, candidate['out'])
2156
- try:
2157
- image = Image.open(image_path)
2158
- if _candidate_is_visual_risk(candidate):
2159
- half = (cell - 3) // 2
2160
- _paste_candidate_preview(sheet, draw, image, (x, y, half, cell))
2161
- _paste_candidate_preview(sheet, draw, image, (x + half + 3, y, cell - half - 3, cell),
2162
- dark=True)
2163
- else:
2164
- _paste_candidate_preview(sheet, draw, image, (x, y, cell, cell))
2165
- except Exception:
2166
- draw.text((x + 8, y + 8), 'unreadable', fill=(200, 0, 0))
2167
- draw.rectangle([x, y, x + cell, y + cell], outline=(120, 120, 128))
2168
- probe = candidate.get('probe') or {}
2169
- alpha = candidate.get('effective_alpha_mean')
2170
- if alpha is None:
2171
- alpha = probe.get('alpha_mean')
2172
- risk = (' a=%s w=%s' % (
2173
- '?' if alpha is None else round(alpha),
2174
- '?' if probe.get('near_white_ratio') is None
2175
- else round(probe['near_white_ratio'] * 100),
2176
- )) if _candidate_is_visual_risk(candidate) else ''
2177
- draw.text((x + 2, y + cell + 3), '[%s] %s %dx%d%s' % (
2178
- candidate['id'], candidate['file'], probe.get('w') or 0, probe.get('h') or 0, risk),
2179
- fill=(20, 20, 24))
2180
- first = (candidate.get('placements') or [{}])[0]
2181
- total_placements = len(candidate.get('source_placements') or
2182
- candidate.get('placements') or [])
2183
- if first.get('slide'):
2184
- box = first['box']
2185
- draw.text((x + 2, y + cell + 18), 's%d @%d,%d %dx%d seen=%d' % (
2186
- first['slide'], box[0], box[1], box[2], box[3],
2187
- total_placements), fill=(20, 20, 24))
2188
- else:
2189
- draw.text((x + 2, y + cell + 18), 'layout x%d' % len(candidate.get('placements') or []),
2190
- fill=(20, 20, 24))
2191
- _save_visual_sheet(sheet, path)
2192
- return path
2193
-
2194
-
2195
- def emit_asset_vision_groups(outdir, candidates, slide_count, ldir):
2196
- """生成受预算约束的拼版和结构化索引,返回已选/未选组。"""
2197
- groups = build_asset_vision_groups(candidates)
2198
- selected, omitted = select_asset_vision_groups(groups, slide_count)
2199
- _safe_remove(os.path.join(ldir, 'vision-group-*.jpg'))
2200
- _safe_remove(os.path.join(ldir, 'contact-sheet-*.png'))
2201
- _safe_remove(os.path.join(ldir, 'contact-sheet.png'))
2202
- _safe_remove(os.path.join(ldir, 'asset-context-sheet-*.png'))
2203
-
2204
- paths = []
2205
- if not has_pillow():
2206
- omitted = groups
2207
- selected = []
2208
- else:
2209
- pages = sorted({page for group in selected for page in group['pages'] if page > 0})
2210
- png_dir = render_asset_vision_pages(outdir, pages)
2211
- try:
2212
- for index, group in enumerate(selected, 1):
2213
- path = os.path.join(ldir, 'vision-group-%d.jpg' % index)
2214
- if asset_vision_group_sheet(outdir, group, png_dir, path):
2215
- paths.append(path)
2216
- group['sheet'] = os.path.basename(path)
2217
- if selected and len(paths) != len(selected):
2218
- raise RuntimeError('视觉判断拼版生成失败')
2219
- except VisionContextError:
2220
- raise
2221
- except Exception:
2222
- _safe_remove(os.path.join(ldir, 'vision-group-*.jpg'))
2223
- selected, omitted, paths = [], groups, []
2224
- if paths:
2225
- # 旧流程只认 contact-sheet.png;保留首个视觉组的 PNG 别名,新的 BRIEF 不再要求读它。
2226
- try:
2227
- from PIL import Image
2228
- legacy = os.path.join(ldir, 'contact-sheet-1.png')
2229
- Image.open(paths[0]).convert('RGB').save(legacy, 'PNG', optimize=True)
2230
- shutil.copy2(legacy, os.path.join(ldir, 'contact-sheet.png'))
2231
- except Exception:
2232
- pass
2233
-
2234
- def serialize(group):
2235
- return {
2236
- 'id': group['id'],
2237
- 'sheet': group.get('sheet'),
2238
- 'pages': group['pages'],
2239
- 'input_count': group['input_count'],
2240
- 'candidates': [{
2241
- 'id': candidate['id'],
2242
- 'source_media': candidate['file'],
2243
- 'source_px': [candidate['probe'].get('w'), candidate['probe'].get('h')],
2244
- 'bytes': candidate.get('bytes'),
2245
- 'repeat_count': candidate.get('n'),
2246
- 'fullscreen': candidate.get('fullscreen'),
2247
- 'effective_alpha_mean': candidate.get('effective_alpha_mean'),
2248
- 'near_white_ratio': candidate['probe'].get('near_white_ratio'),
2249
- 'placements': candidate.get('placements') or [],
2250
- 'source_placements': candidate.get('source_placements') or
2251
- candidate.get('placements') or [],
2252
- } for candidate in group['candidates']],
2253
- }
2254
-
2255
- index = {
2256
- 'version': 2,
2257
- 'limits': {
2258
- 'single_page_image_budget': SINGLE_PAGE_IMAGE_BUDGET,
2259
- 'multi_page_image_budget': MULTI_PAGE_IMAGE_BUDGET,
2260
- 'pack_cap': VISUAL_PACK_CAP,
2261
- 'input_cap': VISUAL_INPUT_CAP,
2262
- },
2263
- 'selected': [serialize(group) for group in selected],
2264
- 'omitted': [serialize(group) for group in omitted],
2265
- }
2266
- with open(os.path.join(ldir, 'asset-vision-groups.json'), 'w', encoding='utf-8') as stream:
2267
- json.dump(index, stream, ensure_ascii=False, indent=2)
2268
- stream.write('\n')
2269
- return selected, omitted, paths
2270
-
2271
-
2272
- def asset_context_sheets(outdir, cands, ldir):
2273
- """按候选主所在页去重拼整页语境,供模型识别 logo 墙和装饰用途。"""
2274
- reviewed = [c for c in cands if needs_asset_judgment(c)]
2275
- pages = []
2276
- seen = set()
2277
- for c in reviewed:
2278
- page = next((no for no in c.get('slides') or [] if no and no != 9999), None)
2279
- if page is not None and page not in seen:
2280
- seen.add(page)
2281
- pages.append(page)
2282
- if not pages:
2283
- return []
2284
- import subprocess
2285
- result = subprocess.run(
2286
- [sys.executable, os.path.join(HERE, 'render_pages.py'), outdir,
2287
- '--pages', 'slides', '--only', ','.join(map(str, pages)), '--no-html'],
2288
- capture_output=True, text=True,
2289
- )
2290
- png_dir = os.path.join(outdir, 'ref', 'rebuild', 'png')
2291
- if result.returncode or not os.path.isdir(png_dir):
2292
- return []
2293
- try:
2294
- from PIL import Image, ImageDraw
2295
- except Exception:
2296
- return []
2297
- paths = []
2298
- candidate_ids = defaultdict(list)
2299
- for index, c in enumerate(cands, 1):
2300
- if not needs_asset_judgment(c):
2301
- continue
2302
- for page in c.get('slides') or []:
2303
- if page in seen:
2304
- candidate_ids[page].append(index)
2305
- for start in range(0, len(pages), CONTEXT_BATCH):
2306
- batch = pages[start:start + CONTEXT_BATCH]
2307
- cols, cw, ch, pad, lab = 2, 480, 270, 16, 22
2308
- rows = (len(batch) + cols - 1) // cols
2309
- sheet = Image.new('RGB', (cols * (cw + pad) + pad,
2310
- rows * (ch + pad + lab) + pad), (245, 245, 247))
2311
- draw = ImageDraw.Draw(sheet)
2312
- for offset, page in enumerate(batch):
2313
- x = pad + (offset % cols) * (cw + pad)
2314
- y = pad + (offset // cols) * (ch + pad + lab)
2315
- source = os.path.join(png_dir, 'slide-%d.png' % page)
2316
- if os.path.exists(source):
2317
- image = Image.open(source).convert('RGB')
2318
- image.thumbnail((cw, ch))
2319
- sheet.paste(image, (x, y))
2320
- draw.rectangle([x, y, x + cw, y + ch], outline=(120, 120, 128))
2321
- draw.text((x + 2, y + ch + 5), 'slide %d candidates=%s'
2322
- % (page, ','.join(map(str, candidate_ids[page]))),
2323
- fill=(20, 20, 24))
2324
- path = os.path.join(ldir, 'asset-context-sheet-%d.png'
2325
- % (start // CONTEXT_BATCH + 1))
2326
- sheet.save(path, optimize=True)
2327
- paths.append(path)
2328
- return paths
2329
-
2330
-
2331
1566
  # ---------------------------------------------------------------- 落盘
2332
1567
  def write(p, s):
2333
1568
  with open(p, 'w', encoding='utf-8') as f:
2334
1569
  f.write(s)
2335
1570
 
2336
1571
 
2337
- def bound_visual_candidates(candidates, archetypes):
2338
- """只把最终有图片槽的候选交给判断单;其余仍留在联系表供视觉核对。"""
2339
- bound_files = {
2340
- s.get('source_media')
2341
- for a in archetypes
2342
- for s in a.get('slots') or []
2343
- if s.get('source_media')
2344
- }
2345
- return [c for c in candidates if c.get('file') in bound_files]
2346
-
2347
-
2348
- def visual_slot_candidates(candidates, archetypes):
2349
- """把候选绑定到最终槽位;页面截图只作该槽位的视觉语境。"""
2350
- source_candidates = {
2351
- candidate.get('file'): candidate
2352
- for candidate in bound_visual_candidates(candidates, archetypes)
2353
- }
2354
- rows, seen = [], set()
2355
- for archetype in archetypes:
2356
- pages = set(archetype.get('pages') or archetype.get('_sample_pages') or [])
2357
- for slot in archetype.get('slots') or []:
2358
- source = slot.get('source_media')
2359
- raw_box = slot.get('box')
2360
- candidate = source_candidates.get(source)
2361
- if not pages or not candidate or not raw_box or not needs_asset_judgment(candidate):
2362
- continue
2363
- box = [round(value) for value in raw_box]
2364
- key = (source, archetype['name'], tuple(box))
2365
- if key in seen:
2366
- continue
2367
- seen.add(key)
2368
- source_placements = candidate.get('placements') or []
2369
- matching = [
2370
- placement for placement in source_placements
2371
- if tuple(placement.get('box') or ()) == tuple(box)
2372
- and placement.get('slide') in pages
2373
- ]
2374
- if not matching:
2375
- continue
2376
- slide = matching[0]['slide']
2377
- row = dict(candidate)
2378
- row['placements'] = [{
2379
- 'slide': slide,
2380
- 'box': box,
2381
- 'archetype': archetype['name'],
2382
- }]
2383
- row['slides'] = [slide] if slide else []
2384
- row['source_placements'] = source_placements
2385
- rows.append(row)
2386
- return rows
2387
-
2388
-
2389
- def emit_manifest(d, assets, vision_groups, ldir, archetypes=()):
1572
+ def emit_manifest(d, assets, ldir):
2390
1573
  L = ['version: alpha',
2391
1574
  'name: TODO-style-name # 英文 kebab,体现气质,不要用文件名',
2392
1575
  'name_zh: TODO中文名',
@@ -2407,37 +1590,6 @@ def emit_manifest(d, assets, vision_groups, ldir, archetypes=()):
2407
1590
  L.append(' on-bg: %s' % (a.get('on_bg') or 'light'))
2408
1591
  if a['use_full']:
2409
1592
  L.append(' use_full: true')
2410
- if vision_groups:
2411
- L += [
2412
- 'asset_vision_groups:',
2413
- ' # 每项对应拼版中的一个候选实例;同源图在不同页型/位置可分别定性。',
2414
- ' # 取值与 FaaS 对齐:logo|slogan|background|texture|icon|decorative|illustration|photo|chart|screenshot|footer-copyright|page-number|watermark|content-image|unknown。',
2415
- ]
2416
- for group in vision_groups:
2417
- for candidate in group['candidates']:
2418
- placement = (candidate.get('placements') or [{}])[0]
2419
- L.append(' - id: %s' % candidate['id'])
2420
- L.append(' source_media: %s' % q(candidate['file']))
2421
- if placement.get('box'):
2422
- L.append(' box: %s' % placement['box'])
2423
- L.append(' visual_kind: TODO-visual-kind-%s # %s;视觉组 %s'
2424
- % (candidate['id'], candidate['id'], group['id']))
2425
- L += [
2426
- 'asset_decisions:',
2427
- ' # 仅在不在视觉预算内的图片、或需要覆盖已有判断时追加。',
2428
- ' # 位置例外写 box;同图同坐标跨页型不同,再补 layout。',
2429
- ' # - {source_media: example.png, visual_kind: chart}',
2430
- ' # - {source_media: example.png, layout: layout-2, box: [0, 0, 100, 100], visual_kind: decorative}',
2431
- ]
2432
- if any(decor.get('trace') == 'canvas-background'
2433
- for archetype in archetypes
2434
- for decor in archetype.get('decor') or []):
2435
- canvas = d['canvas']['px']
2436
- L += [
2437
- 'derived:',
2438
- ' - value: "[0, 0, %d, %d]"' % (canvas[0], canvas[1]),
2439
- ' reason: "PPT 背景铺满画布"',
2440
- ]
2441
1593
  write(os.path.join(ldir, 'manifest.yaml'), '\n'.join(L) + '\n')
2442
1594
 
2443
1595
 
@@ -2534,11 +1686,6 @@ def draft_flow(a, facts, canvas):
2534
1686
  cur.append(items[i + 1])
2535
1687
  regions.append(cur)
2536
1688
 
2537
- # 整页左右边距 = 所有内容的横向外包络,作为各区带的缺省。
2538
- lefts = [s['box'][0] for s in items]
2539
- rights = [s['box'][0] + s['box'][2] for s in items]
2540
- page_margin = [min(lefts), cW - max(rights)]
2541
-
2542
1689
  out = []
2543
1690
  for reg in regions:
2544
1691
  if not reg:
@@ -2561,17 +1708,8 @@ def draft_flow(a, facts, canvas):
2561
1708
  if len(rows) > 1:
2562
1709
  row_gap = round(rows[1][0]['box'][1]
2563
1710
  - (rows[0][0]['box'][1] + rows[0][0]['box'][3]))
2564
- region = {'kind': 'grid', 'cols': cols, 'gap': [max(col_gap, 0), max(row_gap, 0)],
2565
- 'items': rows[0]}
2566
- # 卡片组的横向范围常和整页不同(标题贴左、卡片居中)。整页边距是所有元素的
2567
- # 外包络,直接套给居中卡片组会把它拉偏成左对齐。区带范围和整页明显不一致时,
2568
- # 落这个区带自己的左右边距,消费端把网格放进它再填 1fr。按落盘的整数比较,
2569
- # 亚像素噪声不触发多余的区带边距。
2570
- reg_margin = [min(s['box'][0] for s in rows[0]),
2571
- cW - max(s['box'][0] + s['box'][2] for s in rows[0])]
2572
- if [int(reg_margin[0]), int(reg_margin[1])] != [int(page_margin[0]), int(page_margin[1])]:
2573
- region['margin'] = reg_margin
2574
- out.append(region)
1711
+ out.append({'kind': 'grid', 'cols': cols, 'gap': [max(col_gap, 0), max(row_gap, 0)],
1712
+ 'items': rows[0]})
2575
1713
  elif len(rows) == len(reg):
2576
1714
  # 每行一个元素 = 真的竖着排
2577
1715
  inner = 0
@@ -2586,7 +1724,9 @@ def draft_flow(a, facts, canvas):
2586
1724
  out.append({'kind': 'free', 'items': fixed})
2587
1725
  if len(out) < 2:
2588
1726
  return None
2589
- return {'top': items[0]['box'][1], 'margin': page_margin,
1727
+ lefts = [s['box'][0] for s in items]
1728
+ rights = [s['box'][0] + s['box'][2] for s in items]
1729
+ return {'top': items[0]['box'][1], 'margin': [min(lefts), cW - max(rights)],
2590
1730
  'gap': round(cut), 'regions': out}
2591
1731
 
2592
1732
 
@@ -2735,70 +1875,7 @@ def structure_facts(archetypes, d, shapes):
2735
1875
  return out, recipes
2736
1876
 
2737
1877
 
2738
- LAYOUT_CONTROL_KEYS = {
2739
- 'names', 'roles', 'text_roles', 'layout_modes', 'bg_rules',
2740
- }
2741
-
2742
-
2743
- def emit_layout_controls(layout_lines, ldir, text_role_candidates, flow_archetypes):
2744
- """从兼容用的 layouts.yaml 分出模型只需编辑的判断区。
2745
-
2746
- 旧判断单把控制项和每个 slot 的坐标正文混在一起。模型为补一个角色读取整份文件,
2747
- 在版式很多的模板上会把时间耗在无须判断的数值上。仍保留旧文件给既有调用方;
2748
- 新文件只承载最终可覆盖它的五个顶层判断区。
2749
- """
2750
- blocks, current = {}, None
2751
- for line in layout_lines:
2752
- match = re.match(r'^([A-Za-z_][\w-]*):', line)
2753
- if match:
2754
- current = match.group(1)
2755
- if current in LAYOUT_CONTROL_KEYS:
2756
- blocks[current] = [line]
2757
- continue
2758
- if current in blocks:
2759
- blocks[current].append(line)
2760
-
2761
- controls = [
2762
- '# 版式判断控制区 —— 只读并编辑本文件;不要打开或修改 layouts.yaml。',
2763
- '# package.py 会用本文件覆盖 layouts.yaml 的同名判断区,后者仅保留坐标事实与兼容输入。',
2764
- '# 可编辑顶层键仅为 names / roles / text_roles / layout_modes / bg_rules。',
2765
- ]
2766
- for key in ('names', 'roles'):
2767
- if key in blocks:
2768
- controls.extend([''] + blocks[key])
2769
- if text_role_candidates:
2770
- controls += [
2771
- '',
2772
- 'text_roles:',
2773
- '# 默认文字槽都是 body;仅把确认属于 title|subtitle|header|footer 的例外填为',
2774
- '# <id>: title(不要给普通正文补 body)。候选对应的原始槽位在下列注释中。',
2775
- ]
2776
- for role_id, slot in text_role_candidates:
2777
- controls.append('# %s:%s' % (
2778
- role_id, (slot.get('txt') or '(无样本文字)')[:60]))
2779
- if flow_archetypes:
2780
- controls += [
2781
- '',
2782
- 'layout_modes:',
2783
- '# 默认 slots。只有样张明确需要内容随高度重排时,取消注释并填 `<页型>: flow`。',
2784
- ]
2785
- controls.extend('# %s: flow' % archetype['name'] for archetype in flow_archetypes)
2786
- if 'bg_rules' in blocks:
2787
- controls += [
2788
- '',
2789
- '# 只编辑本草案已列出的真实图片背景;它们都已被 layouts 中的 background: 引用。',
2790
- '# 纯色、渐变、外框、几何装饰和透明叠层不新建 bg_rules;没有本段就保持没有。',
2791
- ] + blocks['bg_rules']
2792
- write(os.path.join(ldir, 'layout-controls.yaml'), '\n'.join(controls) + '\n')
2793
-
2794
-
2795
1878
  def emit_layouts(archetypes, ldir, busy_hints=None, facts=None, recipes=None):
2796
- sampled_archetypes = {
2797
- id(archetype) for archetype in archetypes
2798
- if (archetype.get('rep') is not None
2799
- or archetype.get('pages')
2800
- or archetype.get('_sample_pages'))
2801
- }
2802
1879
  prefilled = sum(1 for a in archetypes if a.get('zh'))
2803
1880
  L = ['# 判断单草案 —— package.py 读它产出 layouts.md,deck 的版式坐标从 layouts.md 读。',
2804
1881
  '# 只改 names / roles / text_roles / layout_modes / bg_rules 五段(都是扁平键值,'
@@ -2826,16 +1903,11 @@ def emit_layouts(archetypes, ldir, busy_hints=None, facts=None, recipes=None):
2826
1903
  for a in need_role:
2827
1904
  szs = sorted({round(s['sz']) for s in a['slots'] if s.get('sz')}, reverse=True)
2828
1905
  L.append(' %s: TODO角色 # 代表页 %s,共 %d 页;文字块 %d 个,字号 %s;'
2829
- '图片 %d 张%s%s'
1906
+ '图片 %d 张%s'
2830
1907
  % (a['name'], a['rep'], len(a['pages']),
2831
1908
  len([s for s in a['slots'] if not s.get('asset')]),
2832
1909
  '/'.join(str(x) for x in szs[:5]) or '未声明',
2833
- a.get('pic_n') or 0, ';有满屏底图' if a.get('bg_raw') else '',
2834
- ';末页候选,结合样张判断 closing 或实际角色'
2835
- if a.get('_last_page_candidate') else ''))
2836
- # 普通正文先保持 body:它是安全且可消费的默认值。标题/页眉/页脚的少量例外依然
2837
- # 要由模型看样张后写入 text_roles;把每一个正文槽都做成 TODO 会迫使模型逐行复述
2838
- # 近百个显然的 body,挤占真正的视觉判断时间。
1910
+ a.get('pic_n') or 0, ';有满屏底图' if a.get('bg_raw') else ''))
2839
1911
  text_role_ids = {}
2840
1912
  for a in archetypes:
2841
1913
  index = 0
@@ -2845,12 +1917,23 @@ def emit_layouts(archetypes, ldir, busy_hints=None, facts=None, recipes=None):
2845
1917
  index += 1
2846
1918
  text_role_ids[id(slot)] = '%s-text-%d' % (a['name'], index)
2847
1919
  if text_role_ids:
2848
- L.append('# 文字槽默认均为 body。看样张后,只把确实属于 title|subtitle|header|footer 的'
2849
- '例外追加到 text_roles:;不要为普通正文逐条补 body。')
1920
+ L.append('text_roles: # 取值 title|subtitle|header|footer|body;只改角色,不删槽')
1921
+ for a in archetypes:
1922
+ for slot in a.get('slots') or []:
1923
+ role_id = text_role_ids.get(id(slot))
1924
+ if not role_id:
1925
+ continue
1926
+ L.append(' %s: TODO文本角色 # 来源 %s;占位符 %s;样例 %s;'
1927
+ 'box %s;字号 %s;css %s'
1928
+ % (role_id, slot.get('_source_layer') or '-',
1929
+ slot.get('_placeholder') or '-', q(slot.get('txt') or ''),
1930
+ slot.get('box'), round(slot.get('sz') or 0),
1931
+ q(slot.get('css') or '未声明')))
2850
1932
  flow_archetypes = [a for a in archetypes if a.get('flow')]
2851
1933
  if flow_archetypes:
2852
- L.append('# 同时有 flow 与 slots 时默认保留 slots,保证固定构图可消费。'
2853
- '只有样张明确需要内容随高度重排时,才在 layout_modes: 中写 <页型>: flow。')
1934
+ L.append('layout_modes: # 取值 flow|slots;内容会变的内容页优先 flow,固定构图页用 slots')
1935
+ for a in flow_archetypes:
1936
+ L.append(' %s: TODO布局模式 # 依据见 layouts 段该页型上方的结构事实' % a['name'])
2854
1937
  # 禁放区是**背景图**的属性,不是页型的属性——按背景资产分组,页型再多也不涨
2855
1938
  bgs = []
2856
1939
  for a in archetypes:
@@ -2879,18 +1962,10 @@ def emit_layouts(archetypes, ldir, busy_hints=None, facts=None, recipes=None):
2879
1962
  y1 = max(b[1] + b[3] for b in boxes)
2880
1963
  L.append(' text_safe: [%d, %d, %d, %d] # 由该背景各页型的槽位并集算出'
2881
1964
  % (x0, y0, x1 - x0, y1 - y0))
2882
- elif any(id(archetype) in sampled_archetypes
2883
- for archetype in archetypes if archetype['bg'] == bg):
2884
- L.append(' text_safe: TODO安全文字区[x,y,w,h](该背景下没有任何槽位可依据)')
2885
1965
  else:
2886
- L.append(' text_safe: [0, 0, 0, 0] # 未见对应样张,没有可依据的文字区')
2887
- if any(id(archetype) in sampled_archetypes
2888
- for archetype in archetypes if archetype['bg'] == bg):
2889
- L.append(' avoid: TODO禁放区列表;无禁放区写 [],有则写 [{box: [x,y,w,h], reason: "..."}]')
2890
- L.append(' pairing_rule: "TODO这张背景上标题/正文/图表要避让哪些区域"')
2891
- else:
2892
- L.append(' avoid: [] # 未见对应样张,不额外推断禁放区')
2893
- L.append(' pairing_rule: "未见对应样张;沿用该页型已有槽位"')
1966
+ L.append(' text_safe: TODO安全文字区[x,y,w,h](该背景下没有任何槽位可依据)')
1967
+ L.append(' avoid: TODO禁放区列表;无禁放区写 [],有则写 [{box: [x,y,w,h], reason: "..."}]')
1968
+ L.append(' pairing_rule: "TODO这张背景上标题/正文/图表要避让哪些区域"')
2894
1969
  L.append('layouts:')
2895
1970
  for a in archetypes:
2896
1971
  fx = (facts or {}).get(a['name']) or {}
@@ -2935,10 +2010,6 @@ def emit_layouts(archetypes, ldir, busy_hints=None, facts=None, recipes=None):
2935
2010
  L.append(' - kind: grid')
2936
2011
  L.append(' cols: %d' % r['cols'])
2937
2012
  L.append(' gap: [%d, %d]' % tuple(r['gap']))
2938
- if r.get('margin'):
2939
- L.append(' margin: [%d, %d] # 本区带自己的左右边距,'
2940
- '和整页 margin 不同(居中卡片组不跟标题的左边距)'
2941
- % tuple(r['margin']))
2942
2013
  elif r['kind'] == 'free':
2943
2014
  L.append(' - kind: free # 推不出规整结构,按 slots 的坐标摆')
2944
2015
  else:
@@ -2966,9 +2037,6 @@ def emit_layouts(archetypes, ldir, busy_hints=None, facts=None, recipes=None):
2966
2037
  extra += ', css: "%s"' % str(child['css']).replace('"', "'")
2967
2038
  if child.get('asset'):
2968
2039
  extra += ', asset: %s' % child['asset']
2969
- if child.get('source_media'):
2970
- extra += ', source_media: %s' % child['source_media']
2971
- extra += ', source_box: %s' % child['box']
2972
2040
  L.append(' - {role: %s, type: %s%s}'
2973
2041
  % (child['role'], child['type'], extra))
2974
2042
  continue
@@ -2987,10 +2055,6 @@ def emit_layouts(archetypes, ldir, busy_hints=None, facts=None, recipes=None):
2987
2055
  extra += ', css: "%s"' % str(s['css']).replace('"', "'")
2988
2056
  if s.get('asset'):
2989
2057
  extra += ', asset: %s' % s['asset']
2990
- if s.get('source_media'):
2991
- extra += ', source_media: %s' % s['source_media']
2992
- if r['kind'] != 'free':
2993
- extra += ', source_box: %s' % s['box']
2994
2058
  L.append(' - {role: %s, type: %s%s}' % (s['role'], s['type'], extra))
2995
2059
  L.append(' slots:')
2996
2060
  for s in a['slots']:
@@ -3000,8 +2064,6 @@ def emit_layouts(archetypes, ldir, busy_hints=None, facts=None, recipes=None):
3000
2064
  extra = ''
3001
2065
  if s.get('asset'):
3002
2066
  extra += ', asset: %s' % s['asset']
3003
- if s.get('source_media'):
3004
- extra += ', source_media: %s' % s['source_media']
3005
2067
  if s.get('css') is not None:
3006
2068
  extra += ', css: "%s"' % str(s['css']).replace('"', "'")
3007
2069
  L.append(' - {role: %s, box: %s, type: %s%s}'
@@ -3013,19 +2075,9 @@ def emit_layouts(archetypes, ldir, busy_hints=None, facts=None, recipes=None):
3013
2075
  % (dcr['box'], dcr['geom'], dcr['css'].replace('"', "'")))
3014
2076
  L.append(' confidence: %s' % a.get('confidence', 'medium'))
3015
2077
  write(os.path.join(ldir, 'layouts.yaml'), '\n'.join(L) + '\n')
3016
- emit_layout_controls(L, ldir, [
3017
- (text_role_ids[id(slot)], slot)
3018
- for archetype in archetypes
3019
- for slot in archetype.get('slots') or []
3020
- if id(archetype) in sampled_archetypes and id(slot) in text_role_ids
3021
- ], [
3022
- archetype for archetype in flow_archetypes
3023
- if id(archetype) in sampled_archetypes
3024
- ])
3025
-
3026
-
3027
- def emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, ldir,
3028
- has_asset_candidates=False):
2078
+
2079
+
2080
+ def emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, ldir):
3029
2081
  """design.md 正文。
3030
2082
 
3031
2083
  每条规则只出现一次——同一条散在 Fast Path / Usage / Background Safety /
@@ -3034,6 +2086,7 @@ def emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, l
3034
2086
  """
3035
2087
  canvas = d['canvas']['px']
3036
2088
  cover = next((a for a in assets if a['id'] == 'bg-cover'), None)
2089
+ logo = next((a for a in assets if a['kind'] == 'logo'), None)
3037
2090
  imp, webs = import_line(fonts)
3038
2091
  sidecar = '`layouts.md`'
3039
2092
 
@@ -3043,10 +2096,6 @@ def emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, l
3043
2096
  % len(archetypes)) if (d.get('form_hint') or {}).get('form') == 3 else
3044
2097
  ('%d 页样张归纳出 %d 种页型。' % (d['counts']['slides'], len(archetypes))))
3045
2098
  L += ['', '## Usage', '',
3046
- '**生成前必须完整阅读本 `design.md` 和 %s,确认全部页型后再开始搭页。**'
3047
- '不能只看摘要、前几个页型或 `## Layouts` 清单;后续页型同样可能定义背景、'
3048
- '安全区、资产和固定元素。' % sidecar,
3049
- '',
3050
2099
  '搭一页 PPT 六步,中间四步的数据都在 %s:' % sidecar, '']
3051
2100
  L += ['1. **定画布** —— 舞台按 `layouts.md` 的 `canvas` 设成 %d×%d,'
3052
2101
  '别套用默认尺寸:源模板的长宽比不一定是 16:9,套错了整页坐标全偏。'
@@ -3055,19 +2104,15 @@ def emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, l
3055
2104
  '2. **挑页型** —— 在 %s 里按用途选一个 archetype(清单见下面 Layouts 段)。'
3056
2105
  '页数多于页型时,挑最接近的一个原样套用它的 slot:用不到的槽删掉,'
3057
2106
  '内容比槽多就按同类槽的间距等距加,**坐标一律沿用该页型给的那套,不要自己另起网格**。'
3058
- '每个生成页面的 `<section>` 都写 `data-pptx-layout="<页型名>"`,'
3059
- '交付前据此核验该页型绑定的背景与图片资产均已使用,且没有跨页型误用。' % sidecar,
2107
+ % sidecar,
3060
2108
  '3. **按页型给的形态落元素** —— 页型给 `flow` 就用流式,给 `slots` 就用绝对,'
3061
2109
  '两者只会出现一个。'
3062
- '**flow**:整块用一个纵向 flex 容器,`top` 是它的起始 y,`margin` 是整块的左右边距,'
2110
+ '**flow**:整块用一个纵向 flex 容器,`top` 是它的起始 y,`margin` 是左右边距,'
3063
2111
  '`gap` 是区带之间的间距;`regions` 从上往下依次排,**每个区带的高度由它自己的'
3064
2112
  '内容决定,不要写死高度**——上面的区带内容变多时,下面的自然被推下去,这正是'
3065
2113
  '这套表达要解决的事。区带内部:`kind: grid` 用 `grid-template-columns: repeat(cols, 1fr)` '
3066
2114
  '配 `gap: [行间距, 列间距]`;`kind: stack` 用纵向 flex 配 `gap`;`kind: free` '
3067
- '按 item 自带的 `box` 绝对定位。区带自带 `margin: [左, 右]` 时用它的、'
3068
- '覆盖整块的 `margin`(模板里居中的卡片组和贴左的标题横向范围本就不同);'
3069
- '没带就用整块的 `margin`。`grid` 在自己这份左右边距里再 `repeat(cols, 1fr)`。'
3070
- '`grid` 里的 `role: group` 是一张卡片:'
2115
+ '按 item 自带的 `box` 绝对定位。`grid` 里的 `role: group` 是一张卡片:'
3071
2116
  'group 的 `css` 用于外层容器,内部 `items` 按顺序纵向排布并使用 group 的 `gap`。'
3072
2117
  '每个 `role: container` 的项是容器,把它的 `css` 逐项原样写进 style,内容放进去;'
3073
2118
  '其中没有 `border-radius` 就按 `0`,不得自行补圆角。',
@@ -3075,13 +2120,8 @@ def emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, l
3075
2120
  '`[x, y, w, h]`(%dx%d 画布上的绝对像素),机械展开成 `left/top/width/height`;'
3076
2121
  'slot 的 `css` 是模板排版属性已转译好的声明串,原样写进 style,不要另选字号、'
3077
2122
  '内边距、颜色或对齐。'
3078
- '带 `asset` 的 slot 是固定图片实例:元素写 `data-pptx-asset="<asset id>"`,'
3079
- '引用复制后的原资产,并把 `box` 直接写成 inline '
3080
- '`position:absolute;left:<x>px;top:<y>px;width:<w>px;height:<h>px`。'
3081
- '元素必须可见,不得省略或换图,也不得隐藏或只在 CSS 里伪装引用;'
3082
- '这个页型没有 `asset` 槽,这一页就不出现该资产。'
3083
- '页型的 `background` 是图片资产时遵循同一实例契约,`box` 使用全画布 '
3084
- '`[0, 0, %d, %d]`。' % (canvas[0], canvas[1], canvas[0], canvas[1]),
2123
+ '带 `asset` 的 slot 是图片元素(logo、角标),把该资产放在它自己的 `box` 里;'
2124
+ '这个页型没有 `asset` 槽,这一页就不出现该资产。' % (canvas[0], canvas[1]),
3085
2125
  '5. **铺装饰几何** —— 页型的 `decor` 是这一页的图形骨架(图标托底的圆、'
3086
2126
  '卡片、分隔线):每条渲染成一个绝对定位空元素,`box` 给位置,`css` 逐项原样写进 '
3087
2127
  'style;没有 `border-radius` 就按 `0`。只有 `geom: ellipse` 另加 '
@@ -3094,7 +2134,7 @@ def emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, l
3094
2134
  '7. **保持标题结构** —— 有合适页型可参考时,沿用该页型已有的标题层级与局部 '
3095
2135
  '`css`;只渲染该页型已有的文字槽,背景中已经可见的固定标题不再创建文本,'
3096
2136
  '页型没有 `subtitle` 槽就不新增副标题。没有合适参考时,按本包整体视觉组织标题。']
3097
- if assets or has_asset_candidates:
2137
+ if assets:
3098
2138
  L += ['', '资产文件(背景由页型的 `background` 字段指定,'
3099
2139
  '图片资产的位置由该页型 `slots` 里带 `asset` 的槽给出):', '',
3100
2140
  '{{ASSET_TABLE}}', '',
@@ -3128,7 +2168,15 @@ def emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, l
3128
2168
  L.append('- 封面页铺满 `bg-cover`,整幅覆盖 %dx%d 画布。' % (canvas[0], canvas[1]))
3129
2169
  if any(a['role'] == 'content' for a in assets):
3130
2170
  L.append('- 内容页的背景由该页型的 `background` 字段指定,整幅铺满。')
3131
- L.append('{{LOGO_RULES}}')
2171
+ if logo:
2172
+ # 点名哪几个页型带 logo。只说「位置去 slots 里查」的话,读起来像是每个页型都有
2173
+ # 这个槽、去查就行——而「不放」是靠该页型 slots 里缺这一项来表达的,要消费端
2174
+ # 自己做否定式推理才能得出。正面点名比让它去发现缺席可靠。
2175
+ with_logo = [a['name'] for a in archetypes
2176
+ if any(str(s.get('asset') or '') == logo['id'] for s in a['slots'])]
2177
+ L.append('- `%s` 只出现在这些页型上:%s;其余页型不放。位置取该页型 `slots` 里 '
2178
+ '`role: logo` 那一项的 `box`,原样使用该文件、保持原比例。'
2179
+ % (logo['id'], '、'.join('`%s`' % x for x in with_logo) or '(无)'))
3132
2180
  L += ['- 坐标、字号、色值、资产位置以 %s 为准;本文件的 Colors / Typography 是可用值的清单。'
3133
2181
  % sidecar,
3134
2182
  '- 强调色族以 Colors 和 %s 的 slot CSS 为主;必要时可以使用 Colors 之外的颜色,'
@@ -3152,20 +2200,9 @@ def emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, l
3152
2200
 
3153
2201
 
3154
2202
  def emit_brief(d, ctx, ldir):
3155
- (tokens, rest, fonts, roles, assets, rejected, todos, archetypes, cands, sheets,
3156
- selected_vision_groups, omitted_vision_groups, leftover, lsheet) = ctx
2203
+ (tokens, rest, fonts, roles, assets, rejected, todos, archetypes, cands, sheet,
2204
+ leftover, lsheet, sheet_n) = ctx
3157
2205
  canvas = d['canvas']['px']
3158
- def sample_pages(archetype):
3159
- return archetype.get('pages') or archetype.get('_sample_pages') or []
3160
-
3161
- sampled_archetypes = [
3162
- archetype for archetype in archetypes
3163
- if archetype.get('rep') is not None or sample_pages(archetype)
3164
- ]
3165
- template_only_archetypes = [
3166
- archetype for archetype in archetypes
3167
- if archetype not in sampled_archetypes
3168
- ]
3169
2206
  L = ['# 抽取简报(第 1/3 步产物;改完草案跑 package.py 出包)', '',
3170
2207
  '源:`%s` 画布 %dx%d %d 页 / %d 版式 主题 %s form=%s'
3171
2208
  % (d['source']['filename'], canvas[0], canvas[1], d['counts']['slides'],
@@ -3175,9 +2212,9 @@ def emit_brief(d, ctx, ldir):
3175
2212
  # 待判断清单从草案实时扫 TODO 生成,不写死:写死的清单会和草案对不上——
3176
2213
  # 既漏掉后加的段(模型读到一半才发现还有活),又在草案已预填时还催人去填。
3177
2214
  HINT = {'manifest.yaml': '看两张图定气质',
3178
- 'layout-controls.yaml': '看 layout-sheet.png;只改这个控制区',
2215
+ 'layouts.yaml': '看 layout-sheet.png;layouts 段本身不要动',
3179
2216
  'body.md': 'Colors 用途列草案已填好,觉得不对再改'}
3180
- for fn in ('manifest.yaml', 'body.md', 'layout-controls.yaml', 'frontmatter.yaml'):
2217
+ for fn in ('manifest.yaml', 'body.md', 'layouts.yaml', 'frontmatter.yaml'):
3181
2218
  path = os.path.join(ldir, fn)
3182
2219
  if not os.path.exists(path):
3183
2220
  continue
@@ -3200,34 +2237,21 @@ def emit_brief(d, ctx, ldir):
3200
2237
  '(%s)' % hint if hint else ''))
3201
2238
  for t in todos:
3202
2239
  L.append('- ' + t)
3203
- L += ['', '## 资产判断(按视觉组一次看完)', '',
3204
- ('视觉判断拼版:%s。每张都含候选独立卡与所在页截图;只读这些拼版,不逐张打开素材。'
3205
- % '、'.join('`l-out/%s`' % os.path.basename(path) for path in sheets))
3206
- if sheets else '(未生成视觉拼版;不要给图片候选定性,已按内容图保留位置并在 gaps 说明。)',
3207
- '`l-out/asset-vision-groups.json` 记录每张候选的原图尺寸、所有页内位置和尺寸;'
3208
- '透明/近白候选在拼版中同时给棋盘格和深灰底预览。',
3209
- '按每个候选实例填 `asset_vision_groups.visual_kind`;同源图在不同页型/位置可不同。'
3210
- '第三方 logo 墙属于 `content-image`,不是 deck 的 `logo`。',
3211
- '',
3212
- '| ID | 文件 | 原图 | 出现 | 页 | 所有位置 |', '|---|---|---|---|---|---|']
2240
+ L += ['', '## 联系表(一次看完所有候选图)', '',
2241
+ '`l-out/contact-sheet.png` —— 图格编号对应下表前几行;看完再决定 logo / 封面归属。' if sheet
2242
+ else '(Pillow 不可用,未生成联系表;逐张看 `media-out/`)', '',
2243
+ '| # | 文件 | 尺寸 | 出现 | 满屏 | 页 | 草案判定 |', '|---|---|---|---|---|---|---|']
3213
2244
  decided = {a['src']['file']: a['id'] for a in assets}
3214
2245
  why = {c['file']: r for c, r in rejected}
3215
- selected_candidates = {
3216
- candidate['file']: candidate
3217
- for group in selected_vision_groups
3218
- for candidate in group['candidates']
3219
- }
3220
- for c in selected_candidates.values():
3221
- L.append('| `%s` | `%s` | %sx%s | %d | %s | `%s` |' % (
3222
- c['id'], c['file'], c['probe'].get('w') or '?', c['probe'].get('h') or '?',
3223
- c['n'], ','.join(map(str, c['slides'])) or 'layout', _placement_text(c)))
3224
- if omitted_vision_groups:
3225
- omitted_pages = sorted({
3226
- page for group in omitted_vision_groups for page in group['pages'] if page > 0
3227
- })
2246
+ for i, c in enumerate(cands, 1):
2247
+ L.append('| %d | `%s` | %sx%s | %d | %s | %s | %s |' % (
2248
+ i, c['file'], c['probe'].get('w') or '?', c['probe'].get('h') or '?', c['n'],
2249
+ 'Y' if c['fullscreen'] else '', ','.join(map(str, c['slides'][:6])) or 'layout',
2250
+ decided.get(c['file']) or ('✗ ' + why.get(c['file'], '未采纳'))))
2251
+ if len(cands) > sheet_n:
3228
2252
  L.append('')
3229
- L.append('未进视觉预算:%s;对应 slot 默认保留内容图片位置,不会自动升为风格资产。'
3230
- % ('第%s页' % '、'.join(map(str, omitted_pages)) if omitted_pages else '版式候选'))
2253
+ L.append('拼版图只含前 %d 张(第 %d 行之后的没有图格)。要看后面某张,'
2254
+ '按文件名直接看 `media-out/`。' % (sheet_n, sheet_n))
3231
2255
  L += ['', '## 颜色(草案 token 已写进 frontmatter.yaml)', '',
3232
2256
  '| token | hex | 出现 |', '|---|---|---|']
3233
2257
  for name, r in tokens:
@@ -3243,55 +2267,24 @@ def emit_brief(d, ctx, ldir):
3243
2267
  L.append('')
3244
2268
  L.append('字号轴:' + '、'.join('%s=%dpx(n=%d)' % (k, round(v['sz_px']), v['n'])
3245
2269
  for k, v in roles.items()))
3246
- L += ['', '## 版式聚类(判断项在 layout-controls.yaml,坐标事实在 layouts.yaml)', '',
3247
- ('`l-out/layout-sheet.png` 是各页型代表页的重建图——**看它给页型起名**,'
3248
- '不用再逐页查 shapes。' if sampled_archetypes else
3249
- '`l-out/layout-sheet.png` 是模板版式层的重建图;用它看整体视觉即可,'
3250
- '没有对应样张的版式已按模板名称预填,不逐项改名或判角色。')
3251
- if lsheet else '(未生成版式图,按下面的 slot 原文命名)', '',
2270
+ L += ['', '## 版式聚类(草案已写进 layouts.yaml)', '',
2271
+ '`l-out/layout-sheet.png` 是各页型代表页的重建图——**看它给页型起名**,'
2272
+ '不用再逐页查 shapes。' if lsheet else '(未生成版式图,按下面的 slot 原文命名)', '',
3252
2273
  '| archetype | 页数 | 代表页 | 背景 | slot 数 |', '|---|---|---|---|---|']
3253
- for a in sampled_archetypes:
3254
- pages = sample_pages(a)
3255
- representative = a.get('rep') or (pages[0] if pages else None)
2274
+ for a in archetypes:
3256
2275
  L.append('| `%s` | %d | %s | %s | %d |' % (
3257
- a['name'], len(pages), representative, a['bg'] or '(无资产底图)', len(a['slots'])))
3258
- if template_only_archetypes:
3259
- L.append('')
3260
- L.append('另有 %d 个模板声明版式没有对应样张:名称、角色和坐标已预填并会进入最终包;'
3261
- '除非当前样张直接证明不对,不需要逐项判断。'
3262
- % len(template_only_archetypes))
3263
- sampled_pages = {
3264
- page: archetype
3265
- for archetype in sampled_archetypes
3266
- for page in sample_pages(archetype)
3267
- }
3268
- first_page = 1
3269
- last_page = d['counts']['slides']
3270
- if first_page in sampled_pages:
3271
- first_archetype = sampled_pages[first_page]
3272
- L.append('')
3273
- L.append('第 1 页实际使用页型:`%s`。若样张确为封面,只在 `roles.%s` 填 `cover`,'
3274
- '不要按页型名称猜。'
3275
- % (first_archetype['name'], first_archetype['name']))
3276
- if last_page != first_page and last_page in sampled_pages:
3277
- last_archetype = sampled_pages[last_page]
3278
- L.append('第 %d 页实际使用页型:`%s`。若样张确为封底,只在 `roles.%s` 填 `closing`,'
3279
- '不要按页型名称猜。'
3280
- % (last_page, last_archetype['name'], last_archetype['name']))
2276
+ a['name'], len(a['pages']), a['rep'], a['bg'] or '(无资产底图)', len(a['slots'])))
3281
2277
  if leftover:
3282
2278
  L += ['', '未归入 archetype 的页:%s —— 都是单页孤例,需要就自己补一个 archetype。'
3283
2279
  % ', '.join(map(str, leftover))]
3284
- L += ['', '有样张页型的 slot 原文(据此起中文页型名,并在 text_roles 判断文本角色):', '']
3285
- for a in sampled_archetypes:
3286
- pages = sample_pages(a)
3287
- representative = a.get('rep') or (pages[0] if pages else None)
3288
- L.append('- `%s`(第 %s 页,覆盖 %s)' % (a['name'], representative, pages))
2280
+ L += ['', '各 archetype 的 slot 原文(据此起中文页型名,并在 text_roles 判断文本角色):', '']
2281
+ for a in archetypes:
2282
+ L.append('- `%s`(第 %s 页,覆盖 %s)' % (a['name'], a['rep'], a['pages']))
3289
2283
  for s in a['slots']:
3290
2284
  L.append(' - %s %spx 「%s」' % (s['role'], round(s['sz']), s['txt']))
3291
2285
  L += ['', '## 下一步', '',
3292
- '1. 并行看全部 `vision-group-*.jpg` 和 `layout-sheet.png`;'
3293
- '2. 先填 asset_vision_groups,再用少量 asset_decisions 写例外,最后一次批量改完其它 TODO;'
3294
- '3. 只改 `layout-controls.yaml` 的版式判断项,再跑 `package.py`。']
2286
+ '1. 看 `contact-sheet.png` 和 `layout-sheet.png`;'
2287
+ '2. 用一次批量编辑/patch 改掉四份草案里的 TODO;3. 跑 `package.py`。']
3295
2288
  write(os.path.join(ldir, 'BRIEF.md'), '\n'.join(L) + '\n')
3296
2289
 
3297
2290
 
@@ -3309,22 +2302,50 @@ def main(argv=None):
3309
2302
  cusage = color_usage(all_shapes, d)
3310
2303
  tokens, rest, rows = draft_colors(d, cusage)
3311
2304
  fonts = draft_fonts(d)
3312
- effective_alpha = fullscreen_effective_alpha(d, outdir, all_shapes)
3313
- archetypes, pages, leftover = draft_layouts(d, outdir, effective_alpha)
3314
- # 只有模板已声明 cover 页型时才能直读它的封面背景。首页和末页会单独保留样张,
3315
- # 但它们的角色仍由模型看图判断,不能因为页码就自动升格为 cover / closing。
3316
- cover_media = cover_background_media(archetypes)
2305
+ archetypes, pages, leftover = draft_layouts(d, outdir)
2306
+ # 封面底图:form=3 的页型键就是角色名(cover/section/...),直接按名字取。
2307
+ # form=2 按样张聚类,键是 layout-1..N,永远匹配不上 'cover'——实测 vo-lite 因此
2308
+ # 一张 role: cover 都没有,封面主视觉被标成 bg-content-1,消费端拿不到封面资产,
2309
+ # design.md 的「封面底图必用 cover 资产」这条硬规则无从满足。回退到覆盖第 1 页的
2310
+ # 那个页型:deck 的第 1 页就是封面,这是版式无关的事实。
2311
+ cover_media = next((a['bg_raw'] for a in archetypes if a['name'] == 'cover'), None)
2312
+ if not cover_media:
2313
+ cover_media = next((a['bg_raw'] for a in archetypes
2314
+ if 1 in (a.get('pages') or ())), None)
3317
2315
  exported_media = {m['media'] for m in d.get('media', []) if m.get('exported')}
3318
2316
  bg_needed = {a['bg_raw'] for a in archetypes if a['bg_raw'] in exported_media}
3319
2317
  bg_under = {p['no']: p.get('rendered_bg') or p['bg_media'] for p in pages}
3320
- assets, rejected, todos, alias, pool = draft_assets(
3321
- d, outdir, bg_needed, cover_media, bg_under, effective_alpha)
2318
+ assets, rejected, todos, alias, pool = draft_assets(d, outdir, bg_needed, cover_media, bg_under)
3322
2319
  media_to_asset = {a['src']['media']: a['id'] for a in assets}
3323
2320
  for m, w in (alias or {}).items():
3324
2321
  if w in media_to_asset:
3325
2322
  media_to_asset.setdefault(m, media_to_asset[w])
3326
2323
 
2324
+ # 版式里那些贴在装饰容器上的小图(图标托底圆里的图标之类):不进包的话,消费端只看到
2325
+ # 一个空圆,只能自己编图形。它们是版式的一部分,按 icon 收进来。
2326
+ ICON_CAP = 12
2327
+ ICON_BUDGET = 3 * 1024 * 1024 # 图标是小件,占包体不该超过背景
3327
2328
  cW, cH = d['canvas']['px']
2329
+ icon_i, icon_bytes = 0, 0
2330
+ for a in archetypes:
2331
+ for s in a['slots']:
2332
+ m = s.get('media')
2333
+ if not m or media_to_asset.get(m) or media_to_asset.get(alias.get(m, m)):
2334
+ continue
2335
+ c = pool.get(alias.get(m, m)) or pool.get(m)
2336
+ if not c or not c.get('out') or icon_i >= ICON_CAP:
2337
+ continue
2338
+ if icon_bytes + (c.get('bytes') or 0) > ICON_BUDGET:
2339
+ continue
2340
+ if s['box'][2] > cW * 0.25 or s['box'][3] > cH * 0.25:
2341
+ continue # 不是图标,是内容配图,交给消费端自备
2342
+ icon_i += 1
2343
+ icon_bytes += c.get('bytes') or 0
2344
+ aid = 'icon-%d' % icon_i
2345
+ assets.append({'id': aid, 'kind': 'icon', 'role': None, 'src': c, 'use_full': False})
2346
+ media_to_asset[c['media']] = aid
2347
+ media_to_asset[m] = aid
2348
+ dropped_slots = []
3328
2349
  for a in archetypes:
3329
2350
  a['bg'] = media_to_asset.get(a['bg_raw'])
3330
2351
  # 版式自带的图片元素:映射到资产 id。映射不到时**保留槽位但不写 asset**——
@@ -3335,25 +2356,22 @@ def main(argv=None):
3335
2356
  if s.get('media'):
3336
2357
  aid = media_to_asset.get(s['media'])
3337
2358
  if not aid:
3338
- c = pool.get(alias.get(s['media'], s['media'])) or pool.get(s['media'])
3339
- s['role'] = 'asset-candidate'
3340
- if c:
3341
- s['source_media'] = c['file']
2359
+ s['role'] = 'icon'
3342
2360
  s.pop('media', None)
2361
+ dropped_slots.append((a['name'], s['box']))
3343
2362
  keep.append(s)
3344
2363
  continue
3345
2364
  s['asset'] = aid
3346
- c = pool.get(alias.get(s['media'], s['media'])) or pool.get(s['media'])
3347
- if c:
3348
- s['source_media'] = c['file']
3349
2365
  # role 跟着资产走:图标槽写成 logo 会让消费端把它当品牌标识,每页都摆一个
3350
2366
  s['role'] = next((x['kind'] for x in assets if x['id'] == aid), s['role'])
3351
2367
  keep.append(s)
3352
2368
  a['slots'] = keep
3353
2369
  roles = draft_scale(d, archetypes)
3354
2370
  slot_added = cover_slot_colors(tokens, archetypes, rows, cusage)
3355
- # 局部图和半透明满屏叠加层必须结合页面语境定性。候选在本阶段按图片槽过滤:
3356
- # 没有最终槽位的媒体无需让模型判断;有槽位但超出视觉预算的则保留通用 pic 槽。
2371
+ # 进包的资产必须全部上联系表。BRIEF 让 L 层「看联系表确认 logo / 封面归属」,
2372
+ # 表上没有的东西它只会从表里另挑一张顶上去。封面主视觉按定义只出现在封面那一页
2373
+ # (n=1),按出现次数排序时排在最末——实测被 cands[:12] 截掉,模型于是把 bg-cover
2374
+ # 换成了已经在用的内容页背景,封面与内容页字节相同,封面主视觉整个丢失。
3357
2375
  decided_c = sorted([a['src'] for a in assets], key=lambda c: (-c['n'], c['file']))
3358
2376
  other_c = sorted([c for c, _ in rejected], key=lambda c: (-c['n'], c['file']))
3359
2377
  cands, seen_file = [], set()
@@ -3361,28 +2379,16 @@ def main(argv=None):
3361
2379
  if c['file'] not in seen_file:
3362
2380
  seen_file.add(c['file'])
3363
2381
  cands.append(c)
3364
- review_candidates = []
3365
- for candidate_index, candidate in enumerate(
3366
- visual_slot_candidates(cands, archetypes), 1):
3367
- row = dict(candidate)
3368
- row['id'] = 'asset-%d' % candidate_index
3369
- review_candidates.append(row)
3370
- selected_vision_groups, omitted_vision_groups, sheets = emit_asset_vision_groups(
3371
- outdir, review_candidates, d['counts']['slides'], ldir)
2382
+ # 表列全部候选,拼版图只拼前几张:两者成本差着数量级。表是文字,60 行也几乎不占
2383
+ # 上下文,却是模型唯一能知道「存在这张图」的地方——名额砍在这里,被误判成未采纳的
2384
+ # 图连翻案的机会都没有。拼版图是要「看」的,60 格就是 4 列×15 行、降采样后每格
2385
+ # 糊成一团,那个上限才有意义。
2386
+ sheet_items = cands[:max(SHEET_CAP, len(decided_c))]
2387
+ sheet = contact_sheet(outdir, sheet_items, os.path.join(ldir, 'contact-sheet.png'))
3372
2388
  lsheet = layout_sheet(outdir, archetypes, os.path.join(ldir, 'layout-sheet.png'))
3373
2389
 
3374
2390
  anchors = draft_anchors(d, tokens, fonts, roles, assets, archetypes)
3375
2391
  gaps, exceptions = [], []
3376
- if omitted_vision_groups:
3377
- omitted_pages = sorted({
3378
- page for group in omitted_vision_groups for page in group['pages'] if page > 0
3379
- })
3380
- if omitted_pages:
3381
- gaps.append('视觉略过:%s页' % '/'.join(map(str, omitted_pages)))
3382
- else:
3383
- gaps.append('视觉判断超预算,未覆盖版式候选')
3384
- if review_candidates and not sheets:
3385
- gaps.append('视觉拼版不可用,图片候选按内容图保留,未做风格定性。')
3386
2392
  for c, why in rejected:
3387
2393
  if '近全透明' in why:
3388
2394
  gaps.append('母版/版式里的 %s 是%s,不是设计资产,任何情况下不要当背景用。' % (c['file'], why))
@@ -3401,6 +2407,10 @@ def main(argv=None):
3401
2407
  gaps.append('%s%s按名额截断:普查到 %d 个,包内留了 %d 个%s。'
3402
2408
  % (kind, at, e['total'], e['kept'],
3403
2409
  ';' + e['advice'] if e['advice'] else ''))
2410
+ if dropped_slots:
2411
+ gaps.append('这些图标槽的源图没有随包分发(超出图标配额或不适合进包):%s。'
2412
+ '槽位保留了坐标,渲染时留空或用中性占位,不要自造图形去填。'
2413
+ % '、'.join('%s %s' % (n, b) for n, b in dropped_slots[:8]))
3404
2414
  # 「没命中映射表」不等于「装不上」:降级目标本身(Noto Sans SC 之类)和 Office 出厂体
3405
2415
  # 都不在 match 列里,但它们本来就可用。真正危险的是**既没命中、又不是已知可用字体**的
3406
2416
  # 那种——design.md 的字体栈里留着一个消费端装不上的商业字体名,且没有任何降级说明。
@@ -3428,7 +2438,7 @@ def main(argv=None):
3428
2438
  exceptions.append('源 deck 第 %s 页是单页孤例,没有归纳成 archetype;需要类似构图时按最接近的页型改。'
3429
2439
  % '、'.join(map(str, leftover)))
3430
2440
 
3431
- emit_manifest(d, assets, selected_vision_groups, ldir, archetypes)
2441
+ emit_manifest(d, assets, ldir)
3432
2442
  emit_frontmatter(d, tokens, fonts, roles, anchors, gaps, ldir)
3433
2443
  # 每张背景量一次局部对比度,作为「哪里不能压文字」的客观依据摆进判断单。
3434
2444
  # 只报测到的数,不替人填 avoid——哪块算主体、要不要避让,是看图才能定的。
@@ -3443,17 +2453,16 @@ def main(argv=None):
3443
2453
  for a in archetypes:
3444
2454
  a['flow'] = draft_flow(a, facts.get(a['name']) or {}, (cW, cH))
3445
2455
  emit_layouts(archetypes, ldir, busy_hints, facts, recipes)
3446
- emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, ldir,
3447
- has_asset_candidates=any(needs_asset_judgment(c) for c in cands))
3448
- emit_brief(d, (tokens, rest, fonts, roles, assets, rejected, todos, archetypes, cands, sheets,
3449
- selected_vision_groups, omitted_vision_groups, leftover, lsheet), ldir)
2456
+ emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, ldir)
2457
+ emit_brief(d, (tokens, rest, fonts, roles, assets, rejected, todos, archetypes, cands, sheet,
2458
+ leftover, lsheet, len(sheet_items)), ldir)
3450
2459
 
3451
2460
  # 这几行落在模型判断「skill 是不是做完了」的那一刻。只报数就会被读成「包已生成」,
3452
2461
  # 于是判断和打包整段被跳过,deck 拿不到任何版式坐标。所以这里报进度与下一条命令。
3453
2462
  print('第 1/3 步完成,判断单草案 -> %s' % ldir)
3454
2463
  print(' 待你确认:资产 %d(%s) 版式 %d 色 %d 字体 %d'
3455
2464
  % (len(assets), ', '.join(x['id'] for x in assets), len(archetypes), len(tokens), len(fonts)))
3456
- print(' 第 2 步 读 l-out/BRIEF.md,并行看视觉组拼版与版式图;版式判断只改 layout-controls.yaml')
2465
+ print(' 第 2 步 读 l-out/BRIEF.md 与 contact-sheet.png,改掉草案里的 TODO')
3457
2466
  print(' 第 3 步 package.py 产出 design.md + layouts.md —— deck 的版式坐标只从这两份读')
3458
2467
  sys.stdout.flush()
3459
2468
  return 0