@lark-apaas/coding-steering 0.1.18-dev.ee5404f → 0.1.18-dev.fb5e270

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.
@@ -5,7 +5,8 @@
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:` 行
@@ -29,7 +30,8 @@ OPAQUE_ENOUGH = 128 # 能当背景的最低不透明度:低于半透明就
29
30
  # 那是叠加装饰不是背景
30
31
  FILL_MANY = 5 # 「被大量当填充铺开」的次数下限,用于区分卡片底与偶发用色
31
32
  BG_CONTENT_CAP = 5 # 内容页背景收几张:再多消费端也挑不过来,超出的写进 TODO 交人取舍
32
- SHEET_CAP = 12 # 联系表展示上限;进包的资产不受它约束,一张都不截
33
+ SHEET_BATCH = 12 # 每张联系表最多 12 个候选;候选不截断,超出就继续生成下一张
34
+ CONTEXT_BATCH = 8 # 每张整页语境表最多 8 页;同页只渲染一次
33
35
 
34
36
  HERE = os.path.dirname(os.path.abspath(__file__))
35
37
  SKILL_ROOT = os.path.dirname(HERE)
@@ -615,6 +617,54 @@ def probe_image(path):
615
617
  return info
616
618
 
617
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
+
618
668
  def bg_busy_map(path, canvas, cells=12):
619
669
  """把背景图切成网格,报每格的**局部对比度**(该格内亮度极差)。
620
670
 
@@ -692,7 +742,8 @@ def copy_logo_candidates(outdir, logo_pool):
692
742
  return rows
693
743
 
694
744
 
695
- 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):
696
747
  imgs = {i['media']: i for i in d['images']}
697
748
  cluster_of = {}
698
749
  for c in d.get('media_clusters', []):
@@ -719,6 +770,7 @@ def draft_assets(d, outdir, bg_needed=(), cover_media=None, bg_under=None):
719
770
  'layer_only': bool(parts) and not slides,
720
771
  'repeat': bool(img.get('repeat_fixed')),
721
772
  'cluster': cluster_of.get(m['media']),
773
+ 'effective_alpha_mean': (effective_alpha or {}).get(m['media']),
722
774
  'probe': probe, 'reasons': m.get('reasons', []),
723
775
  })
724
776
 
@@ -745,14 +797,18 @@ def draft_assets(d, outdir, bg_needed=(), cover_media=None, bg_under=None):
745
797
  bg_i = 0
746
798
  canvas_w, canvas_h = d['canvas']['px']
747
799
  for c in kept:
748
- if c['probe'].get('near_blank'):
749
- 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'])))
750
806
  continue
751
807
  # 铺满 ≠ 能当背景。背景的定义性属性是**遮盖**:它得挡住底下的东西。一张大半透明
752
808
  # 的图铺满整页也遮不住任何像素,它在 PPT 里是叠在幻灯片底色上的一层装饰(顶部
753
809
  # 光晕之类),底色才是真背景。实测某模板一张 alpha 均值 30/255、72% 完全透明的
754
810
  # 顶部光晕被当成满屏背景收进包,消费端每页铺它,顶部就多出一条原稿没有的浓色带。
755
- am = c['probe'].get('alpha_mean')
811
+ am = effective_am if effective_am is not None else c['probe'].get('alpha_mean')
756
812
  if c['fullscreen'] and am is not None and am < OPAQUE_ENOUGH:
757
813
  rejected.append((c, 'alpha 均值只有 %.0f/255,遮不住底下的东西——'
758
814
  '它是叠在底色上的装饰层,不是背景' % am))
@@ -951,19 +1007,21 @@ def slot_style(s):
951
1007
  不会写进消费者产物。
952
1008
  """
953
1009
  txt = s.get('text') or {}
954
- ls = dict((txt.get('lstStyle') or {}).get('lvl1pPr') or {})
1010
+ inherited = dict((txt.get('lstStyle') or {}).get('lvl1pPr') or {})
1011
+ ls = {}
955
1012
  # 四层逐级兜底,按 OOXML 的就近原则:run rPr → 段落 defRPr → 段落 pPr → lstStyle。
956
1013
  # 只枚举前几层会整份漏掉——有的导出器把字号全写在 run rPr 上,lstStyle 一个都没有。
957
1014
  for para in (txt.get('paragraphs') or []):
958
- srcs = [r.get('rPr') or {} for r in (para.get('runs') or [])]
1015
+ srcs = [r for r in (para.get('runs') or [])]
959
1016
  srcs.append(para.get('defRPr') or {})
960
1017
  srcs.append({k: v for k, v in para.items() if k not in ('runs', 'defRPr')})
961
1018
  for src in srcs:
962
1019
  for k, v in (src or {}).items():
963
1020
  if v is not None:
964
1021
  ls.setdefault(k, v)
965
- if ls.get('sz_px'):
966
- break
1022
+ for k, v in inherited.items():
1023
+ if v is not None:
1024
+ ls.setdefault(k, v)
967
1025
  if not ls.get('sz_px'):
968
1026
  # 仍无声明:退到整形状里出现过的最大字号(generic walk),仍是文件里的值
969
1027
  anysz = shape_sz(s)
@@ -982,9 +1040,16 @@ def slot_style(s):
982
1040
  css_number(insets.get('lIns', 0) or 0),
983
1041
  ))
984
1042
  if ls.get('sz_px'):
985
- 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)
986
1048
  css.append('font-size: %dpx' % size)
987
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]))
988
1053
  weight = ls.get('weight') or (700 if ls.get('bold') else None)
989
1054
  if weight:
990
1055
  css.append('font-weight: %s' % weight)
@@ -1020,10 +1085,13 @@ def slot_style(s):
1020
1085
  'l': 'left', 'ctr': 'center', 'r': 'right', 'just': 'justify',
1021
1086
  }.get(align, align))
1022
1087
  line_spacing = ls.get('lnSpc') or {}
1088
+ # normAutofit 的 lnSpcReduction 与 fontScale 同时把行距压缩,一起缩才装得进原框。
1089
+ reduction = body.get('ln_spc_reduction') or 0
1023
1090
  if line_spacing.get('mult'):
1024
- css.append('line-height: %s' % css_number(line_spacing['mult'] * 1.2))
1091
+ mult = line_spacing['mult'] * 1.2 * (1 - reduction)
1092
+ css.append('line-height: %s' % css_number(mult))
1025
1093
  elif line_spacing.get('px'):
1026
- css.append('line-height: %spx' % css_number(line_spacing['px']))
1094
+ css.append('line-height: %spx' % css_number(line_spacing['px'] * (1 - reduction)))
1027
1095
  anchor = body.get('anchor')
1028
1096
  if anchor in ('ctr', 'b'):
1029
1097
  css += ['display: flex', 'flex-direction: column',
@@ -1106,7 +1174,7 @@ def layouts_from_template(d, shapes, cW, cH):
1106
1174
  phs.sort(key=lambda s: ((s['box'].get('y') or 0), (s['box'].get('x') or 0)))
1107
1175
  slots, seen_kind = [], set()
1108
1176
  for s in phs:
1109
- t = PH_TO_TYPE.get((s['ph'] or {}).get('type'), 'body')
1177
+ t = PH_TO_TYPE.get((s.get('ph') or {}).get('type'), 'body')
1110
1178
  if t in ('slide-number', 'footer') and not shape_text(s):
1111
1179
  continue # 空 chrome 占位符不是实际元素
1112
1180
  b = s['box']
@@ -1186,6 +1254,7 @@ def layouts_from_template(d, shapes, cW, cH):
1186
1254
  # 版式名认不出 role 时不装作有把握:置信度降到 low,让 L 层看图定
1187
1255
  'pic_n': 0, 'confidence': 'low' if r.get('role_guessed') else 'high',
1188
1256
  'theme': r['theme'] if multi else None,
1257
+ '_layout_part': r['part'],
1189
1258
  'source': 'layout:' + r['part'].split('/')[-1]})
1190
1259
  return arch
1191
1260
 
@@ -1301,13 +1370,122 @@ def inherited_text_shapes(layout_shapes, slide_shapes):
1301
1370
  return out
1302
1371
 
1303
1372
 
1304
- def draft_layouts(d, outdir):
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):
1305
1476
  with open(os.path.join(outdir, 'ref', 'shapes.json'), encoding='utf-8') as stream:
1306
1477
  shapes = json.load(stream)['shapes']
1307
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
+ }
1308
1485
  if (d.get('form_hint') or {}).get('form') == 3:
1309
1486
  arch = layouts_from_template(d, shapes, cW, cH)
1310
1487
  if len(arch) >= 3:
1488
+ add_template_image_marks(arch, d, overlay_media)
1311
1489
  return arch, [], []
1312
1490
  by_slide = defaultdict(list)
1313
1491
  by_layout = defaultdict(list)
@@ -1316,6 +1494,7 @@ def draft_layouts(d, outdir):
1316
1494
  by_slide[s['part']].append(s)
1317
1495
  elif s.get('layer') == 'layout':
1318
1496
  by_layout[s['part']].append(s)
1497
+ image_marks = slide_image_marks(d, overlay_media)
1319
1498
 
1320
1499
  bg_of_slide, layout_of_slide = {}, {}
1321
1500
  for s in d.get('slides', []):
@@ -1360,10 +1539,25 @@ def draft_layouts(d, outdir):
1360
1539
  })
1361
1540
  texts.sort(key=lambda t: (-t['sz'], t['box'].get('y', 0)))
1362
1541
  visible_shapes = layout_shapes + sh
1363
- pics = [s for s in visible_shapes if s.get('kind') == 'pic' and s.get('w_pct', 0) < 95]
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)
1364
1548
  # 小图元素(logo / 角标 / 装饰)逐页记位置,供 archetype 落 slots
1365
1549
  marks = [{'media': s['media'], 'box': s['box']} for s in pics
1366
- 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)
1367
1561
  pages.append({'part': part, 'no': slide_no(part), 'bg_media': bg_media,
1368
1562
  'rendered_bg': rendered_bg,
1369
1563
  'bg_color': bg_of_slide.get(part), 'texts': texts, 'pic_n': len(pics),
@@ -1398,7 +1592,13 @@ def draft_layouts(d, outdir):
1398
1592
  break
1399
1593
  if g not in kept:
1400
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)
1401
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}
1402
1602
 
1403
1603
  archetypes = []
1404
1604
  for gi, ((bg_raw, _band), ps) in enumerate(kept, 1):
@@ -1421,10 +1621,7 @@ def draft_layouts(d, outdir):
1421
1621
  rest.sort(key=lambda t: (t['box'].get('y', 0), t['box'].get('x', 0)))
1422
1622
  ordered = ([title] if title else []) + rest
1423
1623
  slots = []
1424
- note_truncation('文字槽', 6, len(ordered),
1425
- '需要更多同类槽时,按已有同类槽的间距等距延续,不要另起一套网格'
1426
- '——包里的坐标是模板量出来的,自创网格等于放弃这套版式', name)
1427
- for i, t in enumerate(ordered[:6]):
1624
+ for i, t in enumerate(ordered):
1428
1625
  b = t['box']
1429
1626
  if t.get('needs_role'):
1430
1627
  role = typ = 'body'
@@ -1453,18 +1650,20 @@ def draft_layouts(d, outdir):
1453
1650
  '_placeholder': t.get('placeholder'),
1454
1651
  })
1455
1652
  slots.append(row)
1456
- # 代表页上的小图元素按位置去重后落 slots(同一 logo 在不同页型位置不同)
1653
+ # 同组页面上的图片元素按素材+位置去重后落候选 slots。内容图去掉具体资产引用,
1654
+ # 保留通用图片槽;装饰图绑定资产,避免非代表页上的装饰没有进入 layouts。
1457
1655
  seen_mark = set()
1458
- for mk in rep.get('marks') or []:
1459
- b = mk['box']
1460
- key = (mk['media'], round(b.get('x', 0)), round(b.get('y', 0)))
1461
- if key in seen_mark:
1462
- continue
1463
- seen_mark.add(key)
1464
- slots.append({'role': 'logo', 'type': 'pic', 'sz': 0, 'txt': '',
1465
- 'media': mk['media'],
1466
- 'box': [round(b.get('x', 0)), round(b.get('y', 0)),
1467
- round(b.get('w', 0)), round(b.get('h', 0))]})
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))]})
1468
1667
  taken = {tuple(s['box']) for s in slots}
1469
1668
  decor = []
1470
1669
  seen_decor = set()
@@ -1478,8 +1677,21 @@ def draft_layouts(d, outdir):
1478
1677
  'decor': decor,
1479
1678
  'pages': sorted(p['no'] for p in ps), 'rep': rep['no'],
1480
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']),
1481
1689
  'confidence': 'high' if len(ps) >= 3 else
1482
1690
  ('medium' if len(ps) == 2 else 'low')})
1691
+ # 普通孤例的图片候选仍需 layouts 槽位闭环,但不值得把整页文本升级成正式页型:
1692
+ # 那会为每个孤例增加名称、角色、文本角色和布局模式判断。优先按同源版式承载,
1693
+ # 再按背景、文本密度和相邻页匹配到最接近的真实页型。
1694
+ attach_leftover_image_marks(archetypes, pages, kept_pages)
1483
1695
  return archetypes, pages, leftover
1484
1696
 
1485
1697
 
@@ -1491,13 +1703,19 @@ def layout_sheet(outdir, archetypes, path):
1491
1703
  reps = [x for x in reps if x is not None]
1492
1704
  if not reps:
1493
1705
  return None
1494
- import subprocess
1495
- r = subprocess.run([sys.executable, os.path.join(HERE, 'render_pages.py'), outdir,
1496
- '--pages', 'layouts' if use_layout else 'slides',
1497
- '--only', ','.join(map(str, reps)), '--no-html'],
1498
- capture_output=True, text=True)
1499
1706
  png_dir = os.path.join(outdir, 'ref', 'rebuild', 'png')
1500
- 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):
1501
1719
  return None
1502
1720
  try:
1503
1721
  from PIL import Image, ImageDraw
@@ -1513,7 +1731,7 @@ def layout_sheet(outdir, archetypes, path):
1513
1731
  x = pad + (i % cols) * (cw + pad)
1514
1732
  y = pad + (i // cols) * (ch + pad + lab)
1515
1733
  no = a.get('rep_layout') if use_layout else a.get('rep')
1516
- 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))
1517
1735
  if os.path.exists(f):
1518
1736
  im = Image.open(f).convert('RGB')
1519
1737
  im.thumbnail((cw, ch))
@@ -1530,7 +1748,7 @@ def layout_sheet(outdir, archetypes, path):
1530
1748
  return path
1531
1749
 
1532
1750
 
1533
- def contact_sheet(outdir, cands, path):
1751
+ def contact_sheet(outdir, cands, path, start_index=1):
1534
1752
  try:
1535
1753
  from PIL import Image, ImageDraw
1536
1754
  except Exception:
@@ -1560,19 +1778,95 @@ def contact_sheet(outdir, cands, path):
1560
1778
  dr.text((x + 8, y + 8), 'unreadable', fill=(200, 0, 0))
1561
1779
  dr.rectangle([x, y, x + cell, y + cell], outline=(120, 120, 128))
1562
1780
  dr.text((x + 2, y + cell + 4), '[%d] %s %dx%d used=%d'
1563
- % (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']),
1564
1784
  fill=(20, 20, 24))
1565
1785
  sheet.save(path, optimize=True)
1566
1786
  return path
1567
1787
 
1568
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
+
1569
1863
  # ---------------------------------------------------------------- 落盘
1570
1864
  def write(p, s):
1571
1865
  with open(p, 'w', encoding='utf-8') as f:
1572
1866
  f.write(s)
1573
1867
 
1574
1868
 
1575
- def emit_manifest(d, assets, ldir):
1869
+ def emit_manifest(d, assets, review_candidates, ldir):
1576
1870
  L = ['version: alpha',
1577
1871
  'name: TODO-style-name # 英文 kebab,体现气质,不要用文件名',
1578
1872
  'name_zh: TODO中文名',
@@ -1593,6 +1887,19 @@ def emit_manifest(d, assets, ldir):
1593
1887
  L.append(' on-bg: %s' % (a.get('on_bg') or 'light'))
1594
1888
  if a['use_full']:
1595
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'))
1596
1903
  write(os.path.join(ldir, 'manifest.yaml'), '\n'.join(L) + '\n')
1597
1904
 
1598
1905
 
@@ -1663,9 +1970,13 @@ def draft_flow(a, facts, canvas):
1663
1970
  cW, cH = canvas
1664
1971
  # 装饰件也算进来:很多模板的版式层只有几个占位符,真正撑起版面的是卡片容器
1665
1972
  # (在 decor 里)。只看 slots 会把一页的主体结构整个漏掉。
1666
- items = [s for s in a['slots'] if s.get('box')]
1667
- items += [{'role': 'container', 'type': 'decor', 'box': dcr['box'], 'css': dcr.get('css')}
1668
- for dcr in (a.get('decor') or [])]
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)
1669
1980
  if len(items) < 2:
1670
1981
  return None
1671
1982
  items.sort(key=lambda s: (s['box'][1], s['box'][0]))
@@ -1676,7 +1987,7 @@ def draft_flow(a, facts, canvas):
1676
1987
  return None
1677
1988
  # 区带边界 = 间距分布里的最大空档。同一区带内部的间距(网格行距之类)总是明显
1678
1989
  # 小于区带之间的间距,用本页自己的分布切,不设固定阈值。
1679
- cut = _gap_cut(pos, min(pos), max(pos)) if len(pos) > 1 else max(pos) + 1
1990
+ cut = _gap_cut(pos, min(pos), max(pos)) if len(pos) > 1 else pos[0]
1680
1991
  regions, cur = [], [items[0]]
1681
1992
  for i, g in enumerate(gaps):
1682
1993
  if g >= cut:
@@ -1685,6 +1996,11 @@ def draft_flow(a, facts, canvas):
1685
1996
  cur.append(items[i + 1])
1686
1997
  regions.append(cur)
1687
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
+
1688
2004
  out = []
1689
2005
  for reg in regions:
1690
2006
  if not reg:
@@ -1707,8 +2023,17 @@ def draft_flow(a, facts, canvas):
1707
2023
  if len(rows) > 1:
1708
2024
  row_gap = round(rows[1][0]['box'][1]
1709
2025
  - (rows[0][0]['box'][1] + rows[0][0]['box'][3]))
1710
- out.append({'kind': 'grid', 'cols': cols, 'gap': [max(col_gap, 0), max(row_gap, 0)],
1711
- 'items': rows[0]})
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)
1712
2037
  elif len(rows) == len(reg):
1713
2038
  # 每行一个元素 = 真的竖着排
1714
2039
  inner = 0
@@ -1719,14 +2044,91 @@ def draft_flow(a, facts, canvas):
1719
2044
  # 每行元素数不一致(比如左列两张、右列一张跨两行)。硬说成 stack 会让消费端
1720
2045
  # 以为它们是竖排的,比不给还糟。如实说这块推不出规整结构,按坐标摆。
1721
2046
  out.append({'kind': 'free', 'items': reg})
2047
+ if fixed:
2048
+ out.append({'kind': 'free', 'items': fixed})
1722
2049
  if len(out) < 2:
1723
2050
  return None
1724
- lefts = [s['box'][0] for s in items]
1725
- rights = [s['box'][0] + s['box'][2] for s in items]
1726
- return {'top': items[0]['box'][1], 'margin': [min(lefts), cW - max(rights)],
2051
+ return {'top': items[0]['box'][1], 'margin': page_margin,
1727
2052
  'gap': round(cut), 'regions': out}
1728
2053
 
1729
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
+
1730
2132
  def structure_facts(archetypes, d, shapes):
1731
2133
  """每个页型的**结构事实**:栅格、垂直间距序列、容器样式配方、样张里的实际字数。
1732
2134
 
@@ -1798,7 +2200,7 @@ def structure_facts(archetypes, d, shapes):
1798
2200
  def emit_layouts(archetypes, ldir, busy_hints=None, facts=None, recipes=None):
1799
2201
  prefilled = sum(1 for a in archetypes if a.get('zh'))
1800
2202
  L = ['# 判断单草案 —— package.py 读它产出 layouts.md,deck 的版式坐标从 layouts.md 读。',
1801
- '# 只改 names / roles / text_roles / bg_rules 四段(都是扁平键值,'
2203
+ '# 只改 names / roles / text_roles / layout_modes / bg_rules 五段(都是扁平键值,'
1802
2204
  '改完 package.py 自动并回各页型)。',
1803
2205
  '# 下面 layouts 段是普查数值,一个字都不要动——改它容易连带删掉 slots/confidence。']
1804
2206
  if prefilled:
@@ -1849,6 +2251,11 @@ def emit_layouts(archetypes, ldir, busy_hints=None, facts=None, recipes=None):
1849
2251
  slot.get('_placeholder') or '-', q(slot.get('txt') or ''),
1850
2252
  slot.get('box'), round(slot.get('sz') or 0),
1851
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'])
1852
2259
  # 禁放区是**背景图**的属性,不是页型的属性——按背景资产分组,页型再多也不涨
1853
2260
  bgs = []
1854
2261
  for a in archetypes:
@@ -1915,8 +2322,6 @@ def emit_layouts(archetypes, ldir, busy_hints=None, facts=None, recipes=None):
1915
2322
  L.append(' background: %s' % a['bg'])
1916
2323
  fl = a.get('flow')
1917
2324
  if fl:
1918
- L.append(' # ↓ flow 与 slots 二选一:内容长度会变的页用 flow(区带依次排、'
1919
- '高度由内容定、下面的自动被推下去),构图固定的页用 slots。删掉不要的那个。')
1920
2325
  L.append(' flow:')
1921
2326
  L.append(' top: %d' % fl['top'])
1922
2327
  L.append(' margin: [%d, %d]' % tuple(fl['margin']))
@@ -1927,6 +2332,10 @@ def emit_layouts(archetypes, ldir, busy_hints=None, facts=None, recipes=None):
1927
2332
  L.append(' - kind: grid')
1928
2333
  L.append(' cols: %d' % r['cols'])
1929
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']))
1930
2339
  elif r['kind'] == 'free':
1931
2340
  L.append(' - kind: free # 推不出规整结构,按 slots 的坐标摆')
1932
2341
  else:
@@ -1934,6 +2343,31 @@ def emit_layouts(archetypes, ldir, busy_hints=None, facts=None, recipes=None):
1934
2343
  L.append(' gap: %d' % r['gap'])
1935
2344
  L.append(' items:')
1936
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
1937
2371
  role_id = text_role_ids.get(id(s))
1938
2372
  if role_id:
1939
2373
  L.append(' # text-role: %s' % role_id)
@@ -1949,6 +2383,8 @@ def emit_layouts(archetypes, ldir, busy_hints=None, facts=None, recipes=None):
1949
2383
  extra += ', css: "%s"' % str(s['css']).replace('"', "'")
1950
2384
  if s.get('asset'):
1951
2385
  extra += ', asset: %s' % s['asset']
2386
+ if s.get('source_media'):
2387
+ extra += ', source_media: %s' % s['source_media']
1952
2388
  L.append(' - {role: %s, type: %s%s}' % (s['role'], s['type'], extra))
1953
2389
  L.append(' slots:')
1954
2390
  for s in a['slots']:
@@ -1958,6 +2394,8 @@ def emit_layouts(archetypes, ldir, busy_hints=None, facts=None, recipes=None):
1958
2394
  extra = ''
1959
2395
  if s.get('asset'):
1960
2396
  extra += ', asset: %s' % s['asset']
2397
+ if s.get('source_media'):
2398
+ extra += ', source_media: %s' % s['source_media']
1961
2399
  if s.get('css') is not None:
1962
2400
  extra += ', css: "%s"' % str(s['css']).replace('"', "'")
1963
2401
  L.append(' - {role: %s, box: %s, type: %s%s}'
@@ -1971,7 +2409,8 @@ def emit_layouts(archetypes, ldir, busy_hints=None, facts=None, recipes=None):
1971
2409
  write(os.path.join(ldir, 'layouts.yaml'), '\n'.join(L) + '\n')
1972
2410
 
1973
2411
 
1974
- 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):
1975
2414
  """design.md 正文。
1976
2415
 
1977
2416
  每条规则只出现一次——同一条散在 Fast Path / Usage / Background Safety /
@@ -1980,7 +2419,6 @@ def emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, l
1980
2419
  """
1981
2420
  canvas = d['canvas']['px']
1982
2421
  cover = next((a for a in assets if a['id'] == 'bg-cover'), None)
1983
- logo = next((a for a in assets if a['kind'] == 'logo'), None)
1984
2422
  imp, webs = import_line(fonts)
1985
2423
  sidecar = '`layouts.md`'
1986
2424
 
@@ -2001,14 +2439,18 @@ def emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, l
2001
2439
  % sidecar,
2002
2440
  '3. **按页型给的形态落元素** —— 页型给 `flow` 就用流式,给 `slots` 就用绝对,'
2003
2441
  '两者只会出现一个。'
2004
- '**flow**:整块用一个纵向 flex 容器,`top` 是它的起始 y,`margin` 是左右边距,'
2442
+ '**flow**:整块用一个纵向 flex 容器,`top` 是它的起始 y,`margin` 是整块的左右边距,'
2005
2443
  '`gap` 是区带之间的间距;`regions` 从上往下依次排,**每个区带的高度由它自己的'
2006
2444
  '内容决定,不要写死高度**——上面的区带内容变多时,下面的自然被推下去,这正是'
2007
2445
  '这套表达要解决的事。区带内部:`kind: grid` 用 `grid-template-columns: repeat(cols, 1fr)` '
2008
2446
  '配 `gap: [行间距, 列间距]`;`kind: stack` 用纵向 flex 配 `gap`;`kind: free` '
2009
- '按该页型 `slots` 里的坐标绝对定位。每个 `role: container` 的项是容器,把它的 '
2010
- '`css` 逐项原样写进 style,内容放进去;其中没有 `border-radius` 就按 `0`,'
2011
- '不得自行补圆角。',
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`,不得自行补圆角。',
2012
2454
  '4. **按 slot 落元素(页型给的是 slots 时)** —— 每个 slot 渲染成一个绝对定位元素:`box` 是 '
2013
2455
  '`[x, y, w, h]`(%dx%d 画布上的绝对像素),机械展开成 `left/top/width/height`;'
2014
2456
  'slot 的 `css` 是模板排版属性已转译好的声明串,原样写进 style,不要另选字号、'
@@ -2027,7 +2469,7 @@ def emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, l
2027
2469
  '7. **保持标题结构** —— 有合适页型可参考时,沿用该页型已有的标题层级与局部 '
2028
2470
  '`css`;只渲染该页型已有的文字槽,背景中已经可见的固定标题不再创建文本,'
2029
2471
  '页型没有 `subtitle` 槽就不新增副标题。没有合适参考时,按本包整体视觉组织标题。']
2030
- if assets:
2472
+ if assets or has_asset_candidates:
2031
2473
  L += ['', '资产文件(背景由页型的 `background` 字段指定,'
2032
2474
  '图片资产的位置由该页型 `slots` 里带 `asset` 的槽给出):', '',
2033
2475
  '{{ASSET_TABLE}}', '',
@@ -2061,21 +2503,14 @@ def emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, l
2061
2503
  L.append('- 封面页铺满 `bg-cover`,整幅覆盖 %dx%d 画布。' % (canvas[0], canvas[1]))
2062
2504
  if any(a['role'] == 'content' for a in assets):
2063
2505
  L.append('- 内容页的背景由该页型的 `background` 字段指定,整幅铺满。')
2064
- if logo:
2065
- # 点名哪几个页型带 logo。只说「位置去 slots 里查」的话,读起来像是每个页型都有
2066
- # 这个槽、去查就行——而「不放」是靠该页型 slots 里缺这一项来表达的,要消费端
2067
- # 自己做否定式推理才能得出。正面点名比让它去发现缺席可靠。
2068
- with_logo = [a['name'] for a in archetypes
2069
- if any(str(s.get('asset') or '') == logo['id'] for s in a['slots'])]
2070
- L.append('- `%s` 只出现在这些页型上:%s;其余页型不放。位置取该页型 `slots` 里 '
2071
- '`role: logo` 那一项的 `box`,原样使用该文件、保持原比例。'
2072
- % (logo['id'], '、'.join('`%s`' % x for x in with_logo) or '(无)'))
2506
+ L.append('{{LOGO_RULES}}')
2073
2507
  L += ['- 坐标、字号、色值、资产位置以 %s 为准;本文件的 Colors / Typography 是可用值的清单。'
2074
2508
  % sidecar,
2075
- '- 强调色族以 Colors 和 %s 的 slot CSS 为准,不得自行新增第二强调色。'
2076
- % sidecar,
2077
- '- 允许新增中性色、低彩度辅助色或局部语义色来表达正负、风险、警告、状态、图表序列,'
2078
- '但必须保持辅助层级;只要新色通过高饱和、高对比、大面积或跨页重复获得主视觉权重,'
2509
+ '- 强调色族以 Colors 和 %s 的 slot CSS 为主;必要时可以使用 Colors 之外的颜色,'
2510
+ '但不能形成与模板主色竞争的第二强调色。' % sidecar,
2511
+ '- 新增颜色应与模板整体的色相、明度和饱和度关系协调。允许新增中性色、低彩度辅助色'
2512
+ '或局部语义色表达正负、风险、警告、状态、图表序列,但保持辅助层级;'
2513
+ '只要新色通过高饱和、高对比、大面积或跨页重复获得主视觉权重,'
2079
2514
  '或被用于标题、关键数字、图表主序列、卡片底色或渐变,就属于新的强调色,改用模板'
2080
2515
  '强调色族的深浅、透明度,或改用线型、纹理、标签区分。',
2081
2516
  '- 交付前逐页检查:色板、字体、版式、背景、资产和本段规则均来自本风格包;'
@@ -2092,8 +2527,8 @@ def emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, l
2092
2527
 
2093
2528
 
2094
2529
  def emit_brief(d, ctx, ldir):
2095
- (tokens, rest, fonts, roles, assets, rejected, todos, archetypes, cands, sheet,
2096
- leftover, lsheet, sheet_n) = ctx
2530
+ (tokens, rest, fonts, roles, assets, rejected, todos, archetypes, cands, sheets,
2531
+ context_sheets, leftover, lsheet) = ctx
2097
2532
  canvas = d['canvas']['px']
2098
2533
  L = ['# 抽取简报(第 1/3 步产物;改完草案跑 package.py 出包)', '',
2099
2534
  '源:`%s` 画布 %dx%d %d 页 / %d 版式 主题 %s form=%s'
@@ -2129,9 +2564,14 @@ def emit_brief(d, ctx, ldir):
2129
2564
  '(%s)' % hint if hint else ''))
2130
2565
  for t in todos:
2131
2566
  L.append('- ' + t)
2132
- L += ['', '## 联系表(一次看完所有候选图)', '',
2133
- '`l-out/contact-sheet.png` —— 图格编号对应下表前几行;看完再决定 logo / 封面归属。' if sheet
2134
- 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
+ '',
2135
2575
  '| # | 文件 | 尺寸 | 出现 | 满屏 | 页 | 草案判定 |', '|---|---|---|---|---|---|---|']
2136
2576
  decided = {a['src']['file']: a['id'] for a in assets}
2137
2577
  why = {c['file']: r for c, r in rejected}
@@ -2140,10 +2580,6 @@ def emit_brief(d, ctx, ldir):
2140
2580
  i, c['file'], c['probe'].get('w') or '?', c['probe'].get('h') or '?', c['n'],
2141
2581
  'Y' if c['fullscreen'] else '', ','.join(map(str, c['slides'][:6])) or 'layout',
2142
2582
  decided.get(c['file']) or ('✗ ' + why.get(c['file'], '未采纳'))))
2143
- if len(cands) > sheet_n:
2144
- L.append('')
2145
- L.append('拼版图只含前 %d 张(第 %d 行之后的没有图格)。要看后面某张,'
2146
- '按文件名直接看 `media-out/`。' % (sheet_n, sheet_n))
2147
2583
  L += ['', '## 颜色(草案 token 已写进 frontmatter.yaml)', '',
2148
2584
  '| token | hex | 出现 |', '|---|---|---|']
2149
2585
  for name, r in tokens:
@@ -2175,7 +2611,7 @@ def emit_brief(d, ctx, ldir):
2175
2611
  for s in a['slots']:
2176
2612
  L.append(' - %s %spx 「%s」' % (s['role'], round(s['sz']), s['txt']))
2177
2613
  L += ['', '## 下一步', '',
2178
- '1. `contact-sheet.png` 和 `layout-sheet.png`;'
2614
+ '1. 并行看全部 `contact-sheet-*.png`、`asset-context-sheet-*.png` 和 `layout-sheet.png`;'
2179
2615
  '2. 用一次批量编辑/patch 改掉四份草案里的 TODO;3. 跑 `package.py`。']
2180
2616
  write(os.path.join(ldir, 'BRIEF.md'), '\n'.join(L) + '\n')
2181
2617
 
@@ -2194,7 +2630,8 @@ def main(argv=None):
2194
2630
  cusage = color_usage(all_shapes, d)
2195
2631
  tokens, rest, rows = draft_colors(d, cusage)
2196
2632
  fonts = draft_fonts(d)
2197
- 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)
2198
2635
  # 封面底图:form=3 的页型键就是角色名(cover/section/...),直接按名字取。
2199
2636
  # form=2 按样张聚类,键是 layout-1..N,永远匹配不上 'cover'——实测 vo-lite 因此
2200
2637
  # 一张 role: cover 都没有,封面主视觉被标成 bg-content-1,消费端拿不到封面资产,
@@ -2207,7 +2644,8 @@ def main(argv=None):
2207
2644
  exported_media = {m['media'] for m in d.get('media', []) if m.get('exported')}
2208
2645
  bg_needed = {a['bg_raw'] for a in archetypes if a['bg_raw'] in exported_media}
2209
2646
  bg_under = {p['no']: p.get('rendered_bg') or p['bg_media'] for p in pages}
2210
- assets, rejected, todos, alias, pool = draft_assets(d, outdir, bg_needed, cover_media, bg_under)
2647
+ assets, rejected, todos, alias, pool = draft_assets(
2648
+ d, outdir, bg_needed, cover_media, bg_under, effective_alpha)
2211
2649
  media_to_asset = {a['src']['media']: a['id'] for a in assets}
2212
2650
  for m, w in (alias or {}).items():
2213
2651
  if w in media_to_asset:
@@ -2237,7 +2675,6 @@ def main(argv=None):
2237
2675
  assets.append({'id': aid, 'kind': 'icon', 'role': None, 'src': c, 'use_full': False})
2238
2676
  media_to_asset[c['media']] = aid
2239
2677
  media_to_asset[m] = aid
2240
- dropped_slots = []
2241
2678
  for a in archetypes:
2242
2679
  a['bg'] = media_to_asset.get(a['bg_raw'])
2243
2680
  # 版式自带的图片元素:映射到资产 id。映射不到时**保留槽位但不写 asset**——
@@ -2248,22 +2685,25 @@ def main(argv=None):
2248
2685
  if s.get('media'):
2249
2686
  aid = media_to_asset.get(s['media'])
2250
2687
  if not aid:
2251
- 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']
2252
2692
  s.pop('media', None)
2253
- dropped_slots.append((a['name'], s['box']))
2254
2693
  keep.append(s)
2255
2694
  continue
2256
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']
2257
2699
  # role 跟着资产走:图标槽写成 logo 会让消费端把它当品牌标识,每页都摆一个
2258
2700
  s['role'] = next((x['kind'] for x in assets if x['id'] == aid), s['role'])
2259
2701
  keep.append(s)
2260
2702
  a['slots'] = keep
2261
2703
  roles = draft_scale(d, archetypes)
2262
2704
  slot_added = cover_slot_colors(tokens, archetypes, rows, cusage)
2263
- # 进包的资产必须全部上联系表。BRIEF 让 L 层「看联系表确认 logo / 封面归属」,
2264
- # 表上没有的东西它只会从表里另挑一张顶上去。封面主视觉按定义只出现在封面那一页
2265
- # (n=1),按出现次数排序时排在最末——实测被 cands[:12] 截掉,模型于是把 bg-cover
2266
- # 换成了已经在用的内容页背景,封面与内容页字节相同,封面主视觉整个丢失。
2705
+ # 全部候选都必须上联系表:装饰图与内容图不能靠尺寸/频次可靠区分,logo 墙更必须结合
2706
+ # 整页语境看。按批次出多张图而不是截断,模型可并行看完,不增加串行判断轮次。
2267
2707
  decided_c = sorted([a['src'] for a in assets], key=lambda c: (-c['n'], c['file']))
2268
2708
  other_c = sorted([c for c, _ in rejected], key=lambda c: (-c['n'], c['file']))
2269
2709
  cands, seen_file = [], set()
@@ -2271,12 +2711,15 @@ def main(argv=None):
2271
2711
  if c['file'] not in seen_file:
2272
2712
  seen_file.add(c['file'])
2273
2713
  cands.append(c)
2274
- # 表列全部候选,拼版图只拼前几张:两者成本差着数量级。表是文字,60 行也几乎不占
2275
- # 上下文,却是模型唯一能知道「存在这张图」的地方——名额砍在这里,被误判成未采纳的
2276
- # 图连翻案的机会都没有。拼版图是要「看」的,60 格就是 4 列×15 行、降采样后每格
2277
- # 糊成一团,那个上限才有意义。
2278
- sheet_items = cands[:max(SHEET_CAP, len(decided_c))]
2279
- sheet = contact_sheet(outdir, sheet_items, os.path.join(ldir, 'contact-sheet.png'))
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)
2280
2723
  lsheet = layout_sheet(outdir, archetypes, os.path.join(ldir, 'layout-sheet.png'))
2281
2724
 
2282
2725
  anchors = draft_anchors(d, tokens, fonts, roles, assets, archetypes)
@@ -2299,10 +2742,6 @@ def main(argv=None):
2299
2742
  gaps.append('%s%s按名额截断:普查到 %d 个,包内留了 %d 个%s。'
2300
2743
  % (kind, at, e['total'], e['kept'],
2301
2744
  ';' + e['advice'] if e['advice'] else ''))
2302
- if dropped_slots:
2303
- gaps.append('这些图标槽的源图没有随包分发(超出图标配额或不适合进包):%s。'
2304
- '槽位保留了坐标,渲染时留空或用中性占位,不要自造图形去填。'
2305
- % '、'.join('%s %s' % (n, b) for n, b in dropped_slots[:8]))
2306
2745
  # 「没命中映射表」不等于「装不上」:降级目标本身(Noto Sans SC 之类)和 Office 出厂体
2307
2746
  # 都不在 match 列里,但它们本来就可用。真正危险的是**既没命中、又不是已知可用字体**的
2308
2747
  # 那种——design.md 的字体栈里留着一个消费端装不上的商业字体名,且没有任何降级说明。
@@ -2330,7 +2769,7 @@ def main(argv=None):
2330
2769
  exceptions.append('源 deck 第 %s 页是单页孤例,没有归纳成 archetype;需要类似构图时按最接近的页型改。'
2331
2770
  % '、'.join(map(str, leftover)))
2332
2771
 
2333
- emit_manifest(d, assets, ldir)
2772
+ emit_manifest(d, assets, cands, ldir)
2334
2773
  emit_frontmatter(d, tokens, fonts, roles, anchors, gaps, ldir)
2335
2774
  # 每张背景量一次局部对比度,作为「哪里不能压文字」的客观依据摆进判断单。
2336
2775
  # 只报测到的数,不替人填 avoid——哪块算主体、要不要避让,是看图才能定的。
@@ -2345,16 +2784,17 @@ def main(argv=None):
2345
2784
  for a in archetypes:
2346
2785
  a['flow'] = draft_flow(a, facts.get(a['name']) or {}, (cW, cH))
2347
2786
  emit_layouts(archetypes, ldir, busy_hints, facts, recipes)
2348
- emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, ldir)
2349
- emit_brief(d, (tokens, rest, fonts, roles, assets, rejected, todos, archetypes, cands, sheet,
2350
- leftover, lsheet, len(sheet_items)), ldir)
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)
2351
2791
 
2352
2792
  # 这几行落在模型判断「skill 是不是做完了」的那一刻。只报数就会被读成「包已生成」,
2353
2793
  # 于是判断和打包整段被跳过,deck 拿不到任何版式坐标。所以这里报进度与下一条命令。
2354
2794
  print('第 1/3 步完成,判断单草案 -> %s' % ldir)
2355
2795
  print(' 待你确认:资产 %d(%s) 版式 %d 色 %d 字体 %d'
2356
2796
  % (len(assets), ', '.join(x['id'] for x in assets), len(archetypes), len(tokens), len(fonts)))
2357
- print(' 第 2 步 读 l-out/BRIEF.md 与 contact-sheet.png,改掉草案里的 TODO')
2797
+ print(' 第 2 步 读 l-out/BRIEF.md,并行看联系表与整页语境图,改掉草案里的 TODO')
2358
2798
  print(' 第 3 步 package.py 产出 design.md + layouts.md —— deck 的版式坐标只从这两份读')
2359
2799
  sys.stdout.flush()
2360
2800
  return 0