@lark-apaas/coding-steering 0.1.32-dev.4f80f68 → 0.1.32-dev.5abff3b
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.
- package/package.json +1 -1
- package/steering/design-html/skills/pptx-style-extract/SKILL.md +18 -16
- package/steering/design-html/skills/pptx-style-extract/scripts/draft.py +1094 -174
- package/steering/design-html/skills/pptx-style-extract/scripts/ooxml.py +18 -1
- package/steering/design-html/skills/pptx-style-extract/scripts/package.py +643 -40
- package/steering/design-html/skills/pptx-style-extract/scripts/test_asset_judgment_package.py +443 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/test_design_consumer_contract.py +1 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/test_flow_layout_contract.py +157 -7
- package/steering/design-html/skills/pptx-style-extract/scripts/test_layout_css.py +1417 -2
- package/steering/design-html/skills/pptx-style-extract/scripts/test_text_role_contract.py +151 -4
- package/steering/design-html/skills/pptx-style-extract/v2-format-spec.md +1 -1
|
@@ -5,18 +5,22 @@
|
|
|
5
5
|
|
|
6
6
|
产出 <stage1-outdir>/l-out/:
|
|
7
7
|
BRIEF.md 唯一必读简报:事实 + 草案依据 + 待判断清单
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
vision-group-*.jpg 候选图与所在页语境拼版
|
|
9
|
+
asset-vision-groups.json 候选来源、位置与预算索引
|
|
10
|
+
manifest.yaml / frontmatter.yaml / layout-controls.yaml / layouts.yaml / body.md
|
|
11
|
+
判断单与坐标事实,可直接进 package.py
|
|
10
12
|
|
|
11
13
|
草案里所有数值都来自 extract.json;凡是需要「像人一样看」才能定的,写成 `TODO:` 行
|
|
12
14
|
(package.py 见 TODO 即 FAIL),由 L 层改掉。
|
|
13
15
|
"""
|
|
14
16
|
import argparse
|
|
15
17
|
import copy
|
|
18
|
+
import glob
|
|
16
19
|
import json
|
|
17
20
|
import os
|
|
18
21
|
import re
|
|
19
22
|
import shutil
|
|
23
|
+
import subprocess
|
|
20
24
|
import sys
|
|
21
25
|
from collections import Counter, defaultdict
|
|
22
26
|
|
|
@@ -29,7 +33,18 @@ OPAQUE_ENOUGH = 128 # 能当背景的最低不透明度:低于半透明就
|
|
|
29
33
|
# 那是叠加装饰不是背景
|
|
30
34
|
FILL_MANY = 5 # 「被大量当填充铺开」的次数下限,用于区分卡片底与偶发用色
|
|
31
35
|
BG_CONTENT_CAP = 5 # 内容页背景收几张:再多消费端也挑不过来,超出的写进 TODO 交人取舍
|
|
32
|
-
|
|
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
|
|
33
48
|
|
|
34
49
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
35
50
|
SKILL_ROOT = os.path.dirname(HERE)
|
|
@@ -598,7 +613,13 @@ def lh_of(t):
|
|
|
598
613
|
|
|
599
614
|
# ---------------------------------------------------------------- 资产
|
|
600
615
|
def probe_image(path):
|
|
601
|
-
info = {
|
|
616
|
+
info = {
|
|
617
|
+
'w': None,
|
|
618
|
+
'h': None,
|
|
619
|
+
'alpha_mean': None,
|
|
620
|
+
'near_blank': False,
|
|
621
|
+
'near_white_ratio': None,
|
|
622
|
+
}
|
|
602
623
|
try:
|
|
603
624
|
from PIL import Image
|
|
604
625
|
except Exception:
|
|
@@ -606,15 +627,72 @@ def probe_image(path):
|
|
|
606
627
|
try:
|
|
607
628
|
im = Image.open(path)
|
|
608
629
|
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]
|
|
609
635
|
if im.mode in ('RGBA', 'LA') or 'transparency' in im.info:
|
|
610
|
-
|
|
611
|
-
info['alpha_mean'] = sum(px) / len(px)
|
|
636
|
+
info['alpha_mean'] = sum(alpha) / len(alpha)
|
|
612
637
|
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)
|
|
613
643
|
except Exception:
|
|
614
644
|
pass
|
|
615
645
|
return info
|
|
616
646
|
|
|
617
647
|
|
|
648
|
+
def needs_asset_judgment(candidate):
|
|
649
|
+
"""局部图和半透明满屏叠加层需要看图定性;不透明满屏图按背景处理。"""
|
|
650
|
+
effective_alpha = candidate.get('effective_alpha_mean')
|
|
651
|
+
if ((candidate.get('probe') or {}).get('near_blank')
|
|
652
|
+
or (effective_alpha is not None and effective_alpha < 13)):
|
|
653
|
+
return False
|
|
654
|
+
if not candidate.get('fullscreen'):
|
|
655
|
+
return True
|
|
656
|
+
alpha = (effective_alpha if effective_alpha is not None
|
|
657
|
+
else (candidate.get('probe') or {}).get('alpha_mean'))
|
|
658
|
+
return alpha is not None and alpha < OPAQUE_ENOUGH
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def fullscreen_effective_alpha(data, outdir, shapes):
|
|
662
|
+
"""满屏图片的实际平均 alpha,包含图片文件 alpha 与 OOXML 形状透明度。"""
|
|
663
|
+
media_out = {row.get('media'): row.get('out') for row in data.get('media') or []
|
|
664
|
+
if row.get('media') and row.get('out')}
|
|
665
|
+
probed = {}
|
|
666
|
+
effective = {}
|
|
667
|
+
for shape in shapes:
|
|
668
|
+
media = shape.get('media')
|
|
669
|
+
if (shape.get('kind') != 'pic' or not media
|
|
670
|
+
or shape.get('w_pct', 0) < 95 or shape.get('h_pct', 0) < 95):
|
|
671
|
+
continue
|
|
672
|
+
if media not in probed:
|
|
673
|
+
out = media_out.get(media)
|
|
674
|
+
probe = probe_image(os.path.join(outdir, out)) if out else {}
|
|
675
|
+
probed[media] = probe.get('alpha_mean')
|
|
676
|
+
source_alpha = probed[media]
|
|
677
|
+
if source_alpha is None:
|
|
678
|
+
source_alpha = 255.0
|
|
679
|
+
try:
|
|
680
|
+
opacity = float(shape.get('opacity', 1.0))
|
|
681
|
+
except (TypeError, ValueError):
|
|
682
|
+
opacity = 1.0
|
|
683
|
+
alpha = source_alpha * max(0.0, min(opacity, 1.0))
|
|
684
|
+
effective[media] = min(effective.get(media, 255.0), alpha)
|
|
685
|
+
return effective
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def fullscreen_overlay_media(data, outdir, shapes):
|
|
689
|
+
"""需要模型判断的满屏叠加层媒体。"""
|
|
690
|
+
return {
|
|
691
|
+
media for media, alpha in fullscreen_effective_alpha(data, outdir, shapes).items()
|
|
692
|
+
if 13 <= alpha < OPAQUE_ENOUGH
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
|
|
618
696
|
def bg_busy_map(path, canvas, cells=12):
|
|
619
697
|
"""把背景图切成网格,报每格的**局部对比度**(该格内亮度极差)。
|
|
620
698
|
|
|
@@ -692,7 +770,8 @@ def copy_logo_candidates(outdir, logo_pool):
|
|
|
692
770
|
return rows
|
|
693
771
|
|
|
694
772
|
|
|
695
|
-
def draft_assets(d, outdir, bg_needed=(), cover_media=None, bg_under=None
|
|
773
|
+
def draft_assets(d, outdir, bg_needed=(), cover_media=None, bg_under=None,
|
|
774
|
+
effective_alpha=None):
|
|
696
775
|
imgs = {i['media']: i for i in d['images']}
|
|
697
776
|
cluster_of = {}
|
|
698
777
|
for c in d.get('media_clusters', []):
|
|
@@ -709,16 +788,35 @@ def draft_assets(d, outdir, bg_needed=(), cover_media=None, bg_under=None):
|
|
|
709
788
|
boxes = img.get('boxes') or []
|
|
710
789
|
top = max(boxes, key=lambda b: b.get('count', 0)) if boxes else {}
|
|
711
790
|
parts = top.get('parts') or []
|
|
712
|
-
|
|
791
|
+
placements, seen_placements = [], set()
|
|
792
|
+
for cluster in boxes:
|
|
793
|
+
box = cluster.get('box') or {}
|
|
794
|
+
rounded = [round(box.get(key, 0)) for key in ('x', 'y', 'w', 'h')]
|
|
795
|
+
for part in cluster.get('parts') or []:
|
|
796
|
+
if '/slides/' in part:
|
|
797
|
+
row = {'slide': slide_no(part), 'box': rounded}
|
|
798
|
+
elif '/slideLayouts/' in part:
|
|
799
|
+
row = {'layout': os.path.basename(part), 'box': rounded}
|
|
800
|
+
else:
|
|
801
|
+
continue
|
|
802
|
+
key = (row.get('slide'), row.get('layout'), tuple(rounded))
|
|
803
|
+
if key not in seen_placements:
|
|
804
|
+
seen_placements.add(key)
|
|
805
|
+
placements.append(row)
|
|
806
|
+
placements.sort(key=lambda row: (
|
|
807
|
+
row.get('slide', 9999), row.get('layout', ''), tuple(row['box'])))
|
|
808
|
+
slides = sorted({row['slide'] for row in placements if row.get('slide')})
|
|
713
809
|
cands.append({
|
|
714
810
|
'media': m['media'], 'file': os.path.basename(out_rel), 'out': out_rel,
|
|
715
811
|
'bytes': m.get('bytes'), 'n': img.get('n', m.get('used_n', 0)),
|
|
716
812
|
'has_compressed': bool(m.get('compressed_out')),
|
|
717
813
|
'fullscreen': bool(img.get('fullscreen')), 'w_pct': img.get('max_w_pct', 0),
|
|
718
814
|
'box': top.get('box') or {}, 'slides': slides,
|
|
815
|
+
'placements': placements,
|
|
719
816
|
'layer_only': bool(parts) and not slides,
|
|
720
817
|
'repeat': bool(img.get('repeat_fixed')),
|
|
721
818
|
'cluster': cluster_of.get(m['media']),
|
|
819
|
+
'effective_alpha_mean': (effective_alpha or {}).get(m['media']),
|
|
722
820
|
'probe': probe, 'reasons': m.get('reasons', []),
|
|
723
821
|
})
|
|
724
822
|
|
|
@@ -745,14 +843,18 @@ def draft_assets(d, outdir, bg_needed=(), cover_media=None, bg_under=None):
|
|
|
745
843
|
bg_i = 0
|
|
746
844
|
canvas_w, canvas_h = d['canvas']['px']
|
|
747
845
|
for c in kept:
|
|
748
|
-
|
|
749
|
-
|
|
846
|
+
effective_am = c.get('effective_alpha_mean')
|
|
847
|
+
if (c['probe'].get('near_blank')
|
|
848
|
+
or (effective_am is not None and effective_am < 13)):
|
|
849
|
+
rejected.append((c, '近全透明(alpha 均值 %.0f/255),PPT 里看不见'
|
|
850
|
+
% (effective_am if effective_am is not None
|
|
851
|
+
else c['probe']['alpha_mean'])))
|
|
750
852
|
continue
|
|
751
853
|
# 铺满 ≠ 能当背景。背景的定义性属性是**遮盖**:它得挡住底下的东西。一张大半透明
|
|
752
854
|
# 的图铺满整页也遮不住任何像素,它在 PPT 里是叠在幻灯片底色上的一层装饰(顶部
|
|
753
855
|
# 光晕之类),底色才是真背景。实测某模板一张 alpha 均值 30/255、72% 完全透明的
|
|
754
856
|
# 顶部光晕被当成满屏背景收进包,消费端每页铺它,顶部就多出一条原稿没有的浓色带。
|
|
755
|
-
am = c['probe'].get('alpha_mean')
|
|
857
|
+
am = effective_am if effective_am is not None else c['probe'].get('alpha_mean')
|
|
756
858
|
if c['fullscreen'] and am is not None and am < OPAQUE_ENOUGH:
|
|
757
859
|
rejected.append((c, 'alpha 均值只有 %.0f/255,遮不住底下的东西——'
|
|
758
860
|
'它是叠在底色上的装饰层,不是背景' % am))
|
|
@@ -816,13 +918,13 @@ def draft_assets(d, outdir, bg_needed=(), cover_media=None, bg_under=None):
|
|
|
816
918
|
for i, (corner, c) in enumerate(logo_pool):
|
|
817
919
|
b = c['box']
|
|
818
920
|
if i == 0:
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
921
|
+
# 贴角、重复只能说明“像 logo”,不能替模型判定。比如一张产品功能角标也会
|
|
922
|
+
# 同时满足这些结构特征;先作为候选保留在联系表与图片槽中,由模型定为 logo
|
|
923
|
+
# 或 content,避免把内容图直接带进风格包。
|
|
924
|
+
rejected.append((c, '贴角重复小图候选(%.0fx%.0f @ %.0f,%.0f,出现 %d 次,'
|
|
925
|
+
'离画布边 %.0f%%),结合样张判断 logo 或 content'
|
|
926
|
+
% (b.get('w', 0), b.get('h', 0), b.get('x', 0), b.get('y', 0),
|
|
927
|
+
c['n'], corner * 50)))
|
|
826
928
|
else:
|
|
827
929
|
rejected.append((c, '重复小图(%.0fx%.0f @ %.0f,%.0f),贴角程度 %.0f%% 不如首选'
|
|
828
930
|
% (b.get('w', 0), b.get('h', 0), b.get('x', 0), b.get('y', 0),
|
|
@@ -854,6 +956,12 @@ def draft_assets(d, outdir, bg_needed=(), cover_media=None, bg_under=None):
|
|
|
854
956
|
return assets, rejected, todos, alias, {c['media']: c for c in kept}
|
|
855
957
|
|
|
856
958
|
|
|
959
|
+
def cover_background_media(archetypes):
|
|
960
|
+
"""只从模板明确命名的 cover 页型读取封面背景。"""
|
|
961
|
+
return next((archetype['bg_raw'] for archetype in archetypes
|
|
962
|
+
if archetype['name'] == 'cover'), None)
|
|
963
|
+
|
|
964
|
+
|
|
857
965
|
# ---------------------------------------------------------------- 版式聚类
|
|
858
966
|
DECOR_MIN = 40.0
|
|
859
967
|
|
|
@@ -951,19 +1059,21 @@ def slot_style(s):
|
|
|
951
1059
|
不会写进消费者产物。
|
|
952
1060
|
"""
|
|
953
1061
|
txt = s.get('text') or {}
|
|
954
|
-
|
|
1062
|
+
inherited = dict((txt.get('lstStyle') or {}).get('lvl1pPr') or {})
|
|
1063
|
+
ls = {}
|
|
955
1064
|
# 四层逐级兜底,按 OOXML 的就近原则:run rPr → 段落 defRPr → 段落 pPr → lstStyle。
|
|
956
1065
|
# 只枚举前几层会整份漏掉——有的导出器把字号全写在 run rPr 上,lstStyle 一个都没有。
|
|
957
1066
|
for para in (txt.get('paragraphs') or []):
|
|
958
|
-
srcs = [r
|
|
1067
|
+
srcs = [r for r in (para.get('runs') or [])]
|
|
959
1068
|
srcs.append(para.get('defRPr') or {})
|
|
960
1069
|
srcs.append({k: v for k, v in para.items() if k not in ('runs', 'defRPr')})
|
|
961
1070
|
for src in srcs:
|
|
962
1071
|
for k, v in (src or {}).items():
|
|
963
1072
|
if v is not None:
|
|
964
1073
|
ls.setdefault(k, v)
|
|
965
|
-
|
|
966
|
-
|
|
1074
|
+
for k, v in inherited.items():
|
|
1075
|
+
if v is not None:
|
|
1076
|
+
ls.setdefault(k, v)
|
|
967
1077
|
if not ls.get('sz_px'):
|
|
968
1078
|
# 仍无声明:退到整形状里出现过的最大字号(generic walk),仍是文件里的值
|
|
969
1079
|
anysz = shape_sz(s)
|
|
@@ -982,9 +1092,16 @@ def slot_style(s):
|
|
|
982
1092
|
css_number(insets.get('lIns', 0) or 0),
|
|
983
1093
|
))
|
|
984
1094
|
if ls.get('sz_px'):
|
|
985
|
-
|
|
1095
|
+
# normAutofit 的 fontScale 是模板让大字装进小框的手段——不乘它,消费端拿到的是
|
|
1096
|
+
# 未缩放字号,字比框高,渐变裁切会把溢出的底部切成透明。缺省 1.0(无 autofit / 无缩放)。
|
|
1097
|
+
scale = body.get('font_scale')
|
|
1098
|
+
raw = ls['sz_px'] * scale if scale else ls['sz_px']
|
|
1099
|
+
size = round(raw)
|
|
986
1100
|
css.append('font-size: %dpx' % size)
|
|
987
1101
|
out['_font_size'] = size
|
|
1102
|
+
typeface = ls.get('ea') or ls.get('latin') or ls.get('cs')
|
|
1103
|
+
if typeface:
|
|
1104
|
+
css.append('font-family: %s' % font_css([typeface]))
|
|
988
1105
|
weight = ls.get('weight') or (700 if ls.get('bold') else None)
|
|
989
1106
|
if weight:
|
|
990
1107
|
css.append('font-weight: %s' % weight)
|
|
@@ -1020,10 +1137,13 @@ def slot_style(s):
|
|
|
1020
1137
|
'l': 'left', 'ctr': 'center', 'r': 'right', 'just': 'justify',
|
|
1021
1138
|
}.get(align, align))
|
|
1022
1139
|
line_spacing = ls.get('lnSpc') or {}
|
|
1140
|
+
# normAutofit 的 lnSpcReduction 与 fontScale 同时把行距压缩,一起缩才装得进原框。
|
|
1141
|
+
reduction = body.get('ln_spc_reduction') or 0
|
|
1023
1142
|
if line_spacing.get('mult'):
|
|
1024
|
-
|
|
1143
|
+
mult = line_spacing['mult'] * 1.2 * (1 - reduction)
|
|
1144
|
+
css.append('line-height: %s' % css_number(mult))
|
|
1025
1145
|
elif line_spacing.get('px'):
|
|
1026
|
-
css.append('line-height: %spx' % css_number(line_spacing['px']))
|
|
1146
|
+
css.append('line-height: %spx' % css_number(line_spacing['px'] * (1 - reduction)))
|
|
1027
1147
|
anchor = body.get('anchor')
|
|
1028
1148
|
if anchor in ('ctr', 'b'):
|
|
1029
1149
|
css += ['display: flex', 'flex-direction: column',
|
|
@@ -1094,6 +1214,9 @@ def layouts_from_template(d, shapes, cW, cH):
|
|
|
1094
1214
|
lay_of_slide = (d.get('reference_graph') or {}).get('layout_of_slide') or {}
|
|
1095
1215
|
used_n = Counter(lay_of_slide.values())
|
|
1096
1216
|
slide_of_layout = {lp: sp for sp, lp in lay_of_slide.items() if used_n[lp] == 1}
|
|
1217
|
+
sample_pages_of_layout = defaultdict(list)
|
|
1218
|
+
for slide_part, layout_part in lay_of_slide.items():
|
|
1219
|
+
sample_pages_of_layout[layout_part].append(slide_no(slide_part))
|
|
1097
1220
|
default_theme = topo.get('default')
|
|
1098
1221
|
multi = len(topo.get('themes') or []) > 1
|
|
1099
1222
|
|
|
@@ -1106,7 +1229,7 @@ def layouts_from_template(d, shapes, cW, cH):
|
|
|
1106
1229
|
phs.sort(key=lambda s: ((s['box'].get('y') or 0), (s['box'].get('x') or 0)))
|
|
1107
1230
|
slots, seen_kind = [], set()
|
|
1108
1231
|
for s in phs:
|
|
1109
|
-
t = PH_TO_TYPE.get((s
|
|
1232
|
+
t = PH_TO_TYPE.get((s.get('ph') or {}).get('type'), 'body')
|
|
1110
1233
|
if t in ('slide-number', 'footer') and not shape_text(s):
|
|
1111
1234
|
continue # 空 chrome 占位符不是实际元素
|
|
1112
1235
|
b = s['box']
|
|
@@ -1186,6 +1309,8 @@ def layouts_from_template(d, shapes, cW, cH):
|
|
|
1186
1309
|
# 版式名认不出 role 时不装作有把握:置信度降到 low,让 L 层看图定
|
|
1187
1310
|
'pic_n': 0, 'confidence': 'low' if r.get('role_guessed') else 'high',
|
|
1188
1311
|
'theme': r['theme'] if multi else None,
|
|
1312
|
+
'_layout_part': r['part'],
|
|
1313
|
+
'_sample_pages': sorted(sample_pages_of_layout.get(r['part']) or []),
|
|
1189
1314
|
'source': 'layout:' + r['part'].split('/')[-1]})
|
|
1190
1315
|
return arch
|
|
1191
1316
|
|
|
@@ -1301,13 +1426,79 @@ def inherited_text_shapes(layout_shapes, slide_shapes):
|
|
|
1301
1426
|
return out
|
|
1302
1427
|
|
|
1303
1428
|
|
|
1304
|
-
def
|
|
1429
|
+
def slide_image_marks(data, included_fullscreen=()):
|
|
1430
|
+
"""从图片普查补齐形状图片填充;它们没有独立 pic 节点,但仍有媒体与坐标。"""
|
|
1431
|
+
allowed_fullscreen = set(included_fullscreen)
|
|
1432
|
+
out = defaultdict(list)
|
|
1433
|
+
for image in data.get('images') or []:
|
|
1434
|
+
media = image.get('media')
|
|
1435
|
+
if not media or (image.get('fullscreen') and media not in allowed_fullscreen):
|
|
1436
|
+
continue
|
|
1437
|
+
for cluster in image.get('boxes') or []:
|
|
1438
|
+
box = cluster.get('box')
|
|
1439
|
+
if not box or not box.get('w'):
|
|
1440
|
+
continue
|
|
1441
|
+
for part in cluster.get('parts') or []:
|
|
1442
|
+
if '/slides/' not in part and '/slideLayouts/' not in part:
|
|
1443
|
+
continue
|
|
1444
|
+
out[part].append({'media': media, 'box': box})
|
|
1445
|
+
return out
|
|
1446
|
+
|
|
1447
|
+
|
|
1448
|
+
def add_template_image_marks(archetypes, data, included_fullscreen=()):
|
|
1449
|
+
"""把版式和实例页的图片填充补进 form=3 页型。"""
|
|
1450
|
+
marks_by_part = slide_image_marks(data, included_fullscreen)
|
|
1451
|
+
layout_of_slide = (data.get('reference_graph') or {}).get('layout_of_slide') or {}
|
|
1452
|
+
by_layout = {archetype.get('_layout_part'): archetype for archetype in archetypes}
|
|
1453
|
+
for part, marks in marks_by_part.items():
|
|
1454
|
+
layout_part = layout_of_slide.get(part, part)
|
|
1455
|
+
archetype = by_layout.get(layout_part)
|
|
1456
|
+
if not archetype:
|
|
1457
|
+
continue
|
|
1458
|
+
seen = {
|
|
1459
|
+
(slot.get('media'), tuple(slot.get('box') or ()))
|
|
1460
|
+
for slot in archetype.get('slots') or []
|
|
1461
|
+
if slot.get('media')
|
|
1462
|
+
}
|
|
1463
|
+
for mark in marks:
|
|
1464
|
+
box = mark['box']
|
|
1465
|
+
rounded = [round(box.get(key, 0)) for key in ('x', 'y', 'w', 'h')]
|
|
1466
|
+
key = (mark['media'], tuple(rounded))
|
|
1467
|
+
if key in seen:
|
|
1468
|
+
continue
|
|
1469
|
+
seen.add(key)
|
|
1470
|
+
archetype['slots'].append({
|
|
1471
|
+
'role': 'logo',
|
|
1472
|
+
'type': 'pic',
|
|
1473
|
+
'sz': 0,
|
|
1474
|
+
'txt': '',
|
|
1475
|
+
'media': mark['media'],
|
|
1476
|
+
'box': rounded,
|
|
1477
|
+
})
|
|
1478
|
+
|
|
1479
|
+
|
|
1480
|
+
def preserve_image_bearing_groups(kept, ranked):
|
|
1481
|
+
"""有图片实例的孤例保留自己的页型,避免把资产绑定到近似但错误的版式。"""
|
|
1482
|
+
return kept + [
|
|
1483
|
+
group for group in ranked
|
|
1484
|
+
if group not in kept and any(page.get('marks') for page in group[1])
|
|
1485
|
+
]
|
|
1486
|
+
|
|
1487
|
+
|
|
1488
|
+
def draft_layouts(d, outdir, effective_alpha=None):
|
|
1305
1489
|
with open(os.path.join(outdir, 'ref', 'shapes.json'), encoding='utf-8') as stream:
|
|
1306
1490
|
shapes = json.load(stream)['shapes']
|
|
1307
1491
|
cW, cH = d['canvas']['px']
|
|
1492
|
+
if effective_alpha is None:
|
|
1493
|
+
effective_alpha = fullscreen_effective_alpha(d, outdir, shapes)
|
|
1494
|
+
overlay_media = {
|
|
1495
|
+
media for media, alpha in effective_alpha.items()
|
|
1496
|
+
if 13 <= alpha < OPAQUE_ENOUGH
|
|
1497
|
+
}
|
|
1308
1498
|
if (d.get('form_hint') or {}).get('form') == 3:
|
|
1309
1499
|
arch = layouts_from_template(d, shapes, cW, cH)
|
|
1310
1500
|
if len(arch) >= 3:
|
|
1501
|
+
add_template_image_marks(arch, d, overlay_media)
|
|
1311
1502
|
return arch, [], []
|
|
1312
1503
|
by_slide = defaultdict(list)
|
|
1313
1504
|
by_layout = defaultdict(list)
|
|
@@ -1316,6 +1507,7 @@ def draft_layouts(d, outdir):
|
|
|
1316
1507
|
by_slide[s['part']].append(s)
|
|
1317
1508
|
elif s.get('layer') == 'layout':
|
|
1318
1509
|
by_layout[s['part']].append(s)
|
|
1510
|
+
image_marks = slide_image_marks(d, overlay_media)
|
|
1319
1511
|
|
|
1320
1512
|
bg_of_slide, layout_of_slide = {}, {}
|
|
1321
1513
|
for s in d.get('slides', []):
|
|
@@ -1360,10 +1552,25 @@ def draft_layouts(d, outdir):
|
|
|
1360
1552
|
})
|
|
1361
1553
|
texts.sort(key=lambda t: (-t['sz'], t['box'].get('y', 0)))
|
|
1362
1554
|
visible_shapes = layout_shapes + sh
|
|
1363
|
-
pics = [
|
|
1555
|
+
pics = []
|
|
1556
|
+
for shape in visible_shapes:
|
|
1557
|
+
if shape.get('kind') != 'pic':
|
|
1558
|
+
continue
|
|
1559
|
+
if shape.get('w_pct', 0) < 95 or shape.get('media') in overlay_media:
|
|
1560
|
+
pics.append(shape)
|
|
1364
1561
|
# 小图元素(logo / 角标 / 装饰)逐页记位置,供 archetype 落 slots
|
|
1365
1562
|
marks = [{'media': s['media'], 'box': s['box']} for s in pics
|
|
1366
|
-
if s.get('media') and (s.get('box') or {}).get('w')
|
|
1563
|
+
if s.get('media') and (s.get('box') or {}).get('w')]
|
|
1564
|
+
seen_marks = {
|
|
1565
|
+
(mark['media'], round(mark['box'].get('x', 0)), round(mark['box'].get('y', 0)))
|
|
1566
|
+
for mark in marks
|
|
1567
|
+
}
|
|
1568
|
+
for mark in image_marks.get(part) or []:
|
|
1569
|
+
key = (mark['media'], round(mark['box'].get('x', 0)),
|
|
1570
|
+
round(mark['box'].get('y', 0)))
|
|
1571
|
+
if key not in seen_marks:
|
|
1572
|
+
seen_marks.add(key)
|
|
1573
|
+
marks.append(mark)
|
|
1367
1574
|
pages.append({'part': part, 'no': slide_no(part), 'bg_media': bg_media,
|
|
1368
1575
|
'rendered_bg': rendered_bg,
|
|
1369
1576
|
'bg_color': bg_of_slide.get(part), 'texts': texts, 'pic_n': len(pics),
|
|
@@ -1379,6 +1586,7 @@ def draft_layouts(d, outdir):
|
|
|
1379
1586
|
n = len(p['texts'])
|
|
1380
1587
|
return 0 if n <= q1 else (1 if n <= q2 else 2)
|
|
1381
1588
|
|
|
1589
|
+
last_page_no = max((p['no'] for p in pages), default=None)
|
|
1382
1590
|
groups = defaultdict(list)
|
|
1383
1591
|
for p in pages:
|
|
1384
1592
|
if p['no'] == 1:
|
|
@@ -1386,18 +1594,30 @@ def draft_layouts(d, outdir):
|
|
|
1386
1594
|
# 并进别的组就会被代表页顶掉、坐标全丢。这只是不合并,不代表它是封面。
|
|
1387
1595
|
groups[('__first__', -1)] = [p]
|
|
1388
1596
|
continue
|
|
1597
|
+
if p['no'] == last_page_no:
|
|
1598
|
+
# 末页也单独保留完整结构:它可能是封底,也可能只是最后一张内容页,脚本
|
|
1599
|
+
# 不替模型下结论。和首页一样,拆组只避免它被聚类代表页吞掉。
|
|
1600
|
+
groups[('__last__', -2)] = [p]
|
|
1601
|
+
continue
|
|
1389
1602
|
groups[(p['bg_media'] or p['bg_color'] or 'none', density_band(p))].append(p)
|
|
1390
1603
|
|
|
1391
1604
|
ranked = sorted(groups.items(), key=lambda kv: (-len(kv[1]), kv[1][0]['no']))
|
|
1392
1605
|
# 首页所在的组一定收——deck 的第一页是模板的门面,孤例也不能被名额挤掉。
|
|
1393
1606
|
# 这只保证它进包,它是不是封面由看图的人定。
|
|
1394
1607
|
first = [g for g in ranked if g[0][0] == '__first__']
|
|
1395
|
-
|
|
1608
|
+
last = [g for g in ranked if g[0][0] == '__last__']
|
|
1609
|
+
kept = first + last + [
|
|
1610
|
+
g for g in ranked
|
|
1611
|
+
if g not in first and g not in last and len(g[1]) >= 2
|
|
1612
|
+
][:max(0, 8 - len(first) - len(last))]
|
|
1396
1613
|
for g in ranked: # 名额没用满就把最大的孤例页也收进来
|
|
1397
1614
|
if len(kept) >= 8:
|
|
1398
1615
|
break
|
|
1399
1616
|
if g not in kept:
|
|
1400
1617
|
kept.append(g)
|
|
1618
|
+
# 图片用途必须与它实际所在的版式绑定。若把图片孤例并到“最接近”页型,装饰会被
|
|
1619
|
+
# 绑定到错误布局;是否为内容图、logo 墙或装饰由后续模型看图判断,不按图片数量猜。
|
|
1620
|
+
kept = preserve_image_bearing_groups(kept, ranked)
|
|
1401
1621
|
leftover = sorted(p['no'] for g in ranked if g not in kept for p in g[1])
|
|
1402
1622
|
|
|
1403
1623
|
archetypes = []
|
|
@@ -1405,6 +1625,8 @@ def draft_layouts(d, outdir):
|
|
|
1405
1625
|
rep = max(ps, key=lambda p: len(p['texts']))
|
|
1406
1626
|
if bg_raw == '__first__':
|
|
1407
1627
|
bg_raw = rep['bg_media'] or rep['bg_color'] or 'none'
|
|
1628
|
+
elif bg_raw == '__last__':
|
|
1629
|
+
bg_raw = rep['bg_media'] or rep['bg_color'] or 'none'
|
|
1408
1630
|
rendered_bg = rep.get('rendered_bg')
|
|
1409
1631
|
if rendered_bg:
|
|
1410
1632
|
bg_raw = rendered_bg
|
|
@@ -1450,18 +1672,20 @@ def draft_layouts(d, outdir):
|
|
|
1450
1672
|
'_placeholder': t.get('placeholder'),
|
|
1451
1673
|
})
|
|
1452
1674
|
slots.append(row)
|
|
1453
|
-
#
|
|
1675
|
+
# 同组页面上的图片元素按素材+位置去重后落候选 slots。内容图去掉具体资产引用,
|
|
1676
|
+
# 保留通用图片槽;装饰图绑定资产,避免非代表页上的装饰没有进入 layouts。
|
|
1454
1677
|
seen_mark = set()
|
|
1455
|
-
for
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1678
|
+
for page in ps:
|
|
1679
|
+
for mk in page.get('marks') or []:
|
|
1680
|
+
b = mk['box']
|
|
1681
|
+
key = (mk['media'], round(b.get('x', 0)), round(b.get('y', 0)))
|
|
1682
|
+
if key in seen_mark:
|
|
1683
|
+
continue
|
|
1684
|
+
seen_mark.add(key)
|
|
1685
|
+
slots.append({'role': 'logo', 'type': 'pic', 'sz': 0, 'txt': '',
|
|
1686
|
+
'media': mk['media'],
|
|
1687
|
+
'box': [round(b.get('x', 0)), round(b.get('y', 0)),
|
|
1688
|
+
round(b.get('w', 0)), round(b.get('h', 0))]})
|
|
1465
1689
|
taken = {tuple(s['box']) for s in slots}
|
|
1466
1690
|
decor = []
|
|
1467
1691
|
seen_decor = set()
|
|
@@ -1475,6 +1699,16 @@ def draft_layouts(d, outdir):
|
|
|
1475
1699
|
'decor': decor,
|
|
1476
1700
|
'pages': sorted(p['no'] for p in ps), 'rep': rep['no'],
|
|
1477
1701
|
'pic_n': rep['pic_n'],
|
|
1702
|
+
'_source_layouts': sorted({
|
|
1703
|
+
p['layout'] for p in ps if p.get('layout')
|
|
1704
|
+
}),
|
|
1705
|
+
'_source_backgrounds': sorted({
|
|
1706
|
+
p.get('rendered_bg') or p.get('bg_media') or p.get('bg_color')
|
|
1707
|
+
for p in ps
|
|
1708
|
+
if p.get('rendered_bg') or p.get('bg_media') or p.get('bg_color')
|
|
1709
|
+
}),
|
|
1710
|
+
'_text_n': len(rep['texts']),
|
|
1711
|
+
'_last_page_candidate': rep['no'] == last_page_no,
|
|
1478
1712
|
'confidence': 'high' if len(ps) >= 3 else
|
|
1479
1713
|
('medium' if len(ps) == 2 else 'low')})
|
|
1480
1714
|
return archetypes, pages, leftover
|
|
@@ -1488,13 +1722,19 @@ def layout_sheet(outdir, archetypes, path):
|
|
|
1488
1722
|
reps = [x for x in reps if x is not None]
|
|
1489
1723
|
if not reps:
|
|
1490
1724
|
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)
|
|
1496
1725
|
png_dir = os.path.join(outdir, 'ref', 'rebuild', 'png')
|
|
1497
|
-
if
|
|
1726
|
+
kind = 'layout' if use_layout else 'slide'
|
|
1727
|
+
missing = [no for no in reps
|
|
1728
|
+
if not os.path.exists(os.path.join(png_dir, '%s-%s.png' % (kind, no)))]
|
|
1729
|
+
if missing:
|
|
1730
|
+
import subprocess
|
|
1731
|
+
r = subprocess.run([sys.executable, os.path.join(HERE, 'render_pages.py'), outdir,
|
|
1732
|
+
'--pages', 'layouts' if use_layout else 'slides',
|
|
1733
|
+
'--only', ','.join(map(str, missing)), '--no-html'],
|
|
1734
|
+
capture_output=True, text=True)
|
|
1735
|
+
if r.returncode:
|
|
1736
|
+
return None
|
|
1737
|
+
if not os.path.isdir(png_dir):
|
|
1498
1738
|
return None
|
|
1499
1739
|
try:
|
|
1500
1740
|
from PIL import Image, ImageDraw
|
|
@@ -1510,7 +1750,7 @@ def layout_sheet(outdir, archetypes, path):
|
|
|
1510
1750
|
x = pad + (i % cols) * (cw + pad)
|
|
1511
1751
|
y = pad + (i // cols) * (ch + pad + lab)
|
|
1512
1752
|
no = a.get('rep_layout') if use_layout else a.get('rep')
|
|
1513
|
-
f = os.path.join(png_dir, '%s-%s.png' % (
|
|
1753
|
+
f = os.path.join(png_dir, '%s-%s.png' % (kind, no))
|
|
1514
1754
|
if os.path.exists(f):
|
|
1515
1755
|
im = Image.open(f).convert('RGB')
|
|
1516
1756
|
im.thumbnail((cw, ch))
|
|
@@ -1527,7 +1767,7 @@ def layout_sheet(outdir, archetypes, path):
|
|
|
1527
1767
|
return path
|
|
1528
1768
|
|
|
1529
1769
|
|
|
1530
|
-
def contact_sheet(outdir, cands, path):
|
|
1770
|
+
def contact_sheet(outdir, cands, path, start_index=1):
|
|
1531
1771
|
try:
|
|
1532
1772
|
from PIL import Image, ImageDraw
|
|
1533
1773
|
except Exception:
|
|
@@ -1557,19 +1797,548 @@ def contact_sheet(outdir, cands, path):
|
|
|
1557
1797
|
dr.text((x + 8, y + 8), 'unreadable', fill=(200, 0, 0))
|
|
1558
1798
|
dr.rectangle([x, y, x + cell, y + cell], outline=(120, 120, 128))
|
|
1559
1799
|
dr.text((x + 2, y + cell + 4), '[%d] %s %dx%d used=%d'
|
|
1560
|
-
% (
|
|
1800
|
+
% (c.get('_candidate_index', start_index + idx), c['file'],
|
|
1801
|
+
c['probe'].get('w') or 0,
|
|
1802
|
+
c['probe'].get('h') or 0, c['n']),
|
|
1561
1803
|
fill=(20, 20, 24))
|
|
1562
1804
|
sheet.save(path, optimize=True)
|
|
1563
1805
|
return path
|
|
1564
1806
|
|
|
1565
1807
|
|
|
1808
|
+
def contact_sheets(outdir, cands, ldir):
|
|
1809
|
+
paths = []
|
|
1810
|
+
legacy = os.path.join(ldir, 'contact-sheet.png')
|
|
1811
|
+
if os.path.exists(legacy):
|
|
1812
|
+
os.remove(legacy)
|
|
1813
|
+
for start in range(0, len(cands), SHEET_BATCH):
|
|
1814
|
+
batch = cands[start:start + SHEET_BATCH]
|
|
1815
|
+
path = os.path.join(ldir, 'contact-sheet-%d.png' % (start // SHEET_BATCH + 1))
|
|
1816
|
+
if contact_sheet(outdir, batch, path, start + 1):
|
|
1817
|
+
paths.append(path)
|
|
1818
|
+
if paths:
|
|
1819
|
+
shutil.copy2(paths[0], legacy)
|
|
1820
|
+
return paths
|
|
1821
|
+
|
|
1822
|
+
|
|
1823
|
+
def asset_vision_contexts(candidates):
|
|
1824
|
+
"""把同一素材的每个归纳页型实例放进对应语境,不只展示最早出现的页面。"""
|
|
1825
|
+
contexts = []
|
|
1826
|
+
for candidate in candidates:
|
|
1827
|
+
placements = candidate.get('placements') or []
|
|
1828
|
+
instance_placements = [row for row in placements if row.get('slide')]
|
|
1829
|
+
if not instance_placements:
|
|
1830
|
+
row = dict(candidate)
|
|
1831
|
+
row['source_placements'] = placements
|
|
1832
|
+
contexts.append(row)
|
|
1833
|
+
continue
|
|
1834
|
+
seen = set()
|
|
1835
|
+
id_counts = Counter()
|
|
1836
|
+
for placement in instance_placements:
|
|
1837
|
+
box = tuple(placement['box'])
|
|
1838
|
+
layout = placement.get('archetype')
|
|
1839
|
+
key = (layout, box) if layout else (placement['slide'], box)
|
|
1840
|
+
if key in seen:
|
|
1841
|
+
continue
|
|
1842
|
+
seen.add(key)
|
|
1843
|
+
row = dict(candidate)
|
|
1844
|
+
base_id = '%s-s%d' % (candidate['id'], placement['slide'])
|
|
1845
|
+
id_counts[base_id] += 1
|
|
1846
|
+
row['id'] = (base_id if id_counts[base_id] == 1
|
|
1847
|
+
else '%s-%d' % (base_id, id_counts[base_id]))
|
|
1848
|
+
row['placements'] = [placement]
|
|
1849
|
+
row['slides'] = [placement['slide']]
|
|
1850
|
+
row['layout'] = layout
|
|
1851
|
+
row['source_placements'] = placements
|
|
1852
|
+
contexts.append(row)
|
|
1853
|
+
return contexts
|
|
1854
|
+
|
|
1855
|
+
|
|
1856
|
+
def _group_input_count(group):
|
|
1857
|
+
# 与 FaaS 一致:每页桶预留一张页面语境图;无实例页的版式候选也占一个图位。
|
|
1858
|
+
page_count = len([page for page in group['pages'] if page > 0]) or 1
|
|
1859
|
+
return len(group['candidates']) + page_count
|
|
1860
|
+
|
|
1861
|
+
|
|
1862
|
+
def build_asset_vision_groups(candidates):
|
|
1863
|
+
"""按 FaaS 的 10/5 图数预算,把候选按所在页组合成视觉判断批次。"""
|
|
1864
|
+
buckets = defaultdict(list)
|
|
1865
|
+
for candidate in asset_vision_contexts(candidates):
|
|
1866
|
+
page = next((row['slide'] for row in candidate.get('placements') or []
|
|
1867
|
+
if row.get('slide')), 0)
|
|
1868
|
+
buckets[page].append(candidate)
|
|
1869
|
+
|
|
1870
|
+
groups, queued = [], []
|
|
1871
|
+
|
|
1872
|
+
def flush_small_pages():
|
|
1873
|
+
if not queued:
|
|
1874
|
+
return
|
|
1875
|
+
current, current_pages, inputs = [], [], 0
|
|
1876
|
+
for page, page_candidates in queued:
|
|
1877
|
+
page_inputs = 1 + len(page_candidates)
|
|
1878
|
+
if current and inputs + page_inputs > MULTI_PAGE_IMAGE_BUDGET:
|
|
1879
|
+
groups.append({'pages': current_pages, 'candidates': current})
|
|
1880
|
+
current, current_pages, inputs = [], [], 0
|
|
1881
|
+
current.extend(page_candidates)
|
|
1882
|
+
current_pages.append(page)
|
|
1883
|
+
inputs += page_inputs
|
|
1884
|
+
if current:
|
|
1885
|
+
groups.append({'pages': current_pages, 'candidates': current})
|
|
1886
|
+
del queued[:]
|
|
1887
|
+
|
|
1888
|
+
for page in sorted(buckets):
|
|
1889
|
+
page_candidates = buckets[page]
|
|
1890
|
+
page_inputs = 1 + len(page_candidates)
|
|
1891
|
+
if page_inputs > MULTI_PAGE_IMAGE_BUDGET:
|
|
1892
|
+
flush_small_pages()
|
|
1893
|
+
per_group = SINGLE_PAGE_IMAGE_BUDGET - 1
|
|
1894
|
+
for start in range(0, len(page_candidates), per_group):
|
|
1895
|
+
groups.append({
|
|
1896
|
+
'pages': [page],
|
|
1897
|
+
'candidates': page_candidates[start:start + per_group],
|
|
1898
|
+
})
|
|
1899
|
+
continue
|
|
1900
|
+
queued.append((page, page_candidates))
|
|
1901
|
+
flush_small_pages()
|
|
1902
|
+
|
|
1903
|
+
for index, group in enumerate(groups, 1):
|
|
1904
|
+
group['id'] = 'vision-%d' % index
|
|
1905
|
+
group['input_count'] = _group_input_count(group)
|
|
1906
|
+
return groups
|
|
1907
|
+
|
|
1908
|
+
|
|
1909
|
+
def select_asset_vision_groups(groups, slide_count):
|
|
1910
|
+
"""受总预算约束选择视觉批次:首页和尾页的所有可容纳分批优先于中间页。"""
|
|
1911
|
+
if not groups:
|
|
1912
|
+
return [], []
|
|
1913
|
+
first_page = 1
|
|
1914
|
+
last_page = slide_count or max(
|
|
1915
|
+
(page for group in groups for page in group['pages'] if page > 0), default=0)
|
|
1916
|
+
selected, selected_ids, inputs = [], set(), 0
|
|
1917
|
+
|
|
1918
|
+
def add(group):
|
|
1919
|
+
nonlocal inputs
|
|
1920
|
+
if (group['id'] in selected_ids or len(selected) >= VISUAL_PACK_CAP
|
|
1921
|
+
or inputs + group['input_count'] > VISUAL_INPUT_CAP):
|
|
1922
|
+
return False
|
|
1923
|
+
selected.append(group)
|
|
1924
|
+
selected_ids.add(group['id'])
|
|
1925
|
+
inputs += group['input_count']
|
|
1926
|
+
return True
|
|
1927
|
+
|
|
1928
|
+
# 首尾页先于中间页保留全部可容纳分批。交错加入避免首页多批先占满总预算,尾页
|
|
1929
|
+
# 连首批都进不去;单页 deck 不重复扫描。
|
|
1930
|
+
priority_batches = [
|
|
1931
|
+
[group for group in groups if page in group['pages']]
|
|
1932
|
+
for page in dict.fromkeys((first_page, last_page))
|
|
1933
|
+
]
|
|
1934
|
+
for batch_index in range(max(map(len, priority_batches), default=0)):
|
|
1935
|
+
for batches in priority_batches:
|
|
1936
|
+
if batch_index < len(batches):
|
|
1937
|
+
add(batches[batch_index])
|
|
1938
|
+
|
|
1939
|
+
# 首尾的第一个批次已经保证;剩余按页码保留前段内容,优先丢弃尾页之前的后段。
|
|
1940
|
+
remainder = sorted(
|
|
1941
|
+
(group for group in groups if group['id'] not in selected_ids),
|
|
1942
|
+
key=lambda group: (
|
|
1943
|
+
min((page for page in group['pages'] if page > 0), default=999999),
|
|
1944
|
+
group['id'],
|
|
1945
|
+
),
|
|
1946
|
+
)
|
|
1947
|
+
for group in remainder:
|
|
1948
|
+
add(group)
|
|
1949
|
+
|
|
1950
|
+
selected.sort(key=lambda group: (
|
|
1951
|
+
min((page for page in group['pages'] if page > 0), default=999999), group['id']))
|
|
1952
|
+
omitted = [group for group in groups if group['id'] not in selected_ids]
|
|
1953
|
+
return selected, omitted
|
|
1954
|
+
|
|
1955
|
+
|
|
1956
|
+
def _safe_remove(pattern):
|
|
1957
|
+
for path in glob.glob(pattern):
|
|
1958
|
+
try:
|
|
1959
|
+
os.remove(path)
|
|
1960
|
+
except OSError:
|
|
1961
|
+
pass
|
|
1962
|
+
|
|
1963
|
+
|
|
1964
|
+
def _fit_image(image, width, height):
|
|
1965
|
+
copy = image.copy()
|
|
1966
|
+
copy.thumbnail((width, height))
|
|
1967
|
+
return copy
|
|
1968
|
+
|
|
1969
|
+
|
|
1970
|
+
def _draw_checkerboard(draw, box, size=14):
|
|
1971
|
+
x, y, w, h = box
|
|
1972
|
+
for row in range(0, h, size):
|
|
1973
|
+
for col in range(0, w, size):
|
|
1974
|
+
if (row // size + col // size) % 2 == 0:
|
|
1975
|
+
draw.rectangle([x + col, y + row, x + col + size - 1, y + row + size - 1],
|
|
1976
|
+
fill=(214, 214, 218))
|
|
1977
|
+
|
|
1978
|
+
|
|
1979
|
+
def _candidate_is_visual_risk(candidate):
|
|
1980
|
+
probe = candidate.get('probe') or {}
|
|
1981
|
+
alpha = candidate.get('effective_alpha_mean')
|
|
1982
|
+
if alpha is None:
|
|
1983
|
+
alpha = probe.get('alpha_mean')
|
|
1984
|
+
return ((alpha is not None and alpha < 230)
|
|
1985
|
+
or (probe.get('near_white_ratio') or 0) >= 0.7)
|
|
1986
|
+
|
|
1987
|
+
|
|
1988
|
+
def _paste_candidate_preview(sheet, draw, image, box, dark=False):
|
|
1989
|
+
x, y, w, h = box
|
|
1990
|
+
if dark:
|
|
1991
|
+
draw.rectangle([x, y, x + w, y + h], fill=(54, 54, 58))
|
|
1992
|
+
else:
|
|
1993
|
+
_draw_checkerboard(draw, box)
|
|
1994
|
+
preview = _fit_image(image.convert('RGBA'), w - 8, h - 8)
|
|
1995
|
+
px = x + (w - preview.width) // 2
|
|
1996
|
+
py = y + (h - preview.height) // 2
|
|
1997
|
+
sheet.paste(preview, (px, py), preview)
|
|
1998
|
+
|
|
1999
|
+
|
|
2000
|
+
def _placement_text(candidate):
|
|
2001
|
+
rows = []
|
|
2002
|
+
for placement in candidate.get('placements') or []:
|
|
2003
|
+
box = placement['box']
|
|
2004
|
+
if placement.get('slide'):
|
|
2005
|
+
rows.append('s%d@%d,%d,%d,%d' % (
|
|
2006
|
+
placement['slide'], box[0], box[1], box[2], box[3]))
|
|
2007
|
+
else:
|
|
2008
|
+
rows.append('%s@%d,%d,%d,%d' % (
|
|
2009
|
+
placement.get('layout') or 'layout', box[0], box[1], box[2], box[3]))
|
|
2010
|
+
return ';'.join(rows)
|
|
2011
|
+
|
|
2012
|
+
|
|
2013
|
+
def _save_visual_sheet(sheet, path):
|
|
2014
|
+
if max(sheet.size) > VISUAL_PREVIEW_MAX_EDGE:
|
|
2015
|
+
ratio = VISUAL_PREVIEW_MAX_EDGE / float(max(sheet.size))
|
|
2016
|
+
sheet = sheet.resize((max(1, round(sheet.width * ratio)),
|
|
2017
|
+
max(1, round(sheet.height * ratio))))
|
|
2018
|
+
sheet.save(path, 'JPEG', quality=VISUAL_JPEG_QUALITY, optimize=True, progressive=True)
|
|
2019
|
+
|
|
2020
|
+
|
|
2021
|
+
def render_asset_vision_pages(outdir, pages):
|
|
2022
|
+
"""视觉判断必须有页面语境;截图失败时中止草案而非让模型盲判。"""
|
|
2023
|
+
if not pages:
|
|
2024
|
+
return None
|
|
2025
|
+
result = subprocess.run(
|
|
2026
|
+
[sys.executable, os.path.join(HERE, 'render_pages.py'), outdir,
|
|
2027
|
+
'--pages', 'slides', '--only', ','.join(map(str, pages)), '--no-html'],
|
|
2028
|
+
capture_output=True, text=True,
|
|
2029
|
+
)
|
|
2030
|
+
png_dir = os.path.join(outdir, 'ref', 'rebuild', 'png')
|
|
2031
|
+
missing = [
|
|
2032
|
+
page for page in pages
|
|
2033
|
+
if not os.path.isfile(os.path.join(png_dir, 'slide-%d.png' % page))
|
|
2034
|
+
]
|
|
2035
|
+
if result.returncode or missing:
|
|
2036
|
+
detail = (result.stderr or result.stdout or '').strip().splitlines()
|
|
2037
|
+
raise RuntimeError(
|
|
2038
|
+
'视觉判断所需页面截图生成失败%s%s' % (
|
|
2039
|
+
'(缺第%s页)' % '、'.join(map(str, missing)) if missing else '',
|
|
2040
|
+
':' + detail[-1] if detail else '',
|
|
2041
|
+
)
|
|
2042
|
+
)
|
|
2043
|
+
return png_dir
|
|
2044
|
+
|
|
2045
|
+
|
|
2046
|
+
class VisionContextError(RuntimeError):
|
|
2047
|
+
pass
|
|
2048
|
+
|
|
2049
|
+
|
|
2050
|
+
def has_pillow():
|
|
2051
|
+
try:
|
|
2052
|
+
from PIL import Image # noqa: F401
|
|
2053
|
+
except Exception:
|
|
2054
|
+
return False
|
|
2055
|
+
return True
|
|
2056
|
+
|
|
2057
|
+
|
|
2058
|
+
def asset_vision_group_sheet(outdir, group, png_dir, path):
|
|
2059
|
+
"""把一组整页语境和候选图做成可索引拼版;每张候选保持独立卡片。"""
|
|
2060
|
+
try:
|
|
2061
|
+
from PIL import Image, ImageDraw
|
|
2062
|
+
except Exception:
|
|
2063
|
+
return None
|
|
2064
|
+
|
|
2065
|
+
candidates = group['candidates']
|
|
2066
|
+
page_count = len([page for page in group['pages'] if page > 0])
|
|
2067
|
+
page_w, page_h = 440, 248
|
|
2068
|
+
cell, pad, label_h = 220, 16, 42
|
|
2069
|
+
asset_cols = 3
|
|
2070
|
+
asset_rows = max(1, (len(candidates) + asset_cols - 1) // asset_cols)
|
|
2071
|
+
page_rows = max(1, (page_count + 1) // 2) if page_count else 0
|
|
2072
|
+
page_cols = min(2, page_count) if page_count else 0
|
|
2073
|
+
width = max(2 * (page_w + pad) + pad if page_cols else 0,
|
|
2074
|
+
asset_cols * (cell + pad) + pad)
|
|
2075
|
+
header_h = 28
|
|
2076
|
+
page_area_h = (page_rows * (page_h + label_h + pad) + pad) if page_rows else 0
|
|
2077
|
+
asset_top = header_h + page_area_h
|
|
2078
|
+
height = asset_top + asset_rows * (cell + label_h + pad) + pad
|
|
2079
|
+
sheet = Image.new('RGB', (width, height), (245, 245, 247))
|
|
2080
|
+
draw = ImageDraw.Draw(sheet)
|
|
2081
|
+
draw.text((pad, 7), '%s inputs=%d pages=%s' % (
|
|
2082
|
+
group['id'], group['input_count'],
|
|
2083
|
+
','.join(map(str, group['pages'])) or 'layout'), fill=(20, 20, 24))
|
|
2084
|
+
|
|
2085
|
+
for index, page in enumerate([page for page in group['pages'] if page > 0]):
|
|
2086
|
+
x = pad + (index % 2) * (page_w + pad)
|
|
2087
|
+
y = header_h + (index // 2) * (page_h + label_h + pad)
|
|
2088
|
+
source = (os.path.join(png_dir, 'slide-%d.png' % page)
|
|
2089
|
+
if png_dir else None)
|
|
2090
|
+
if source and os.path.exists(source):
|
|
2091
|
+
try:
|
|
2092
|
+
image = Image.open(source).convert('RGB')
|
|
2093
|
+
image = _fit_image(image, page_w, page_h)
|
|
2094
|
+
sheet.paste(image, (x + (page_w - image.width) // 2,
|
|
2095
|
+
y + (page_h - image.height) // 2))
|
|
2096
|
+
except Exception as exc:
|
|
2097
|
+
raise VisionContextError('视觉判断所需页面截图不可读取:第%d页' % page) from exc
|
|
2098
|
+
else:
|
|
2099
|
+
raise VisionContextError('视觉判断所需页面截图缺失:第%d页' % page)
|
|
2100
|
+
draw.rectangle([x, y, x + page_w, y + page_h], outline=(120, 120, 128))
|
|
2101
|
+
draw.text((x + 2, y + page_h + 5), '[page %d] context for candidates below' % page,
|
|
2102
|
+
fill=(20, 20, 24))
|
|
2103
|
+
|
|
2104
|
+
for index, candidate in enumerate(candidates):
|
|
2105
|
+
x = pad + (index % asset_cols) * (cell + pad)
|
|
2106
|
+
y = asset_top + (index // asset_cols) * (cell + label_h + pad)
|
|
2107
|
+
image_path = os.path.join(outdir, candidate['out'])
|
|
2108
|
+
try:
|
|
2109
|
+
image = Image.open(image_path)
|
|
2110
|
+
if _candidate_is_visual_risk(candidate):
|
|
2111
|
+
half = (cell - 3) // 2
|
|
2112
|
+
_paste_candidate_preview(sheet, draw, image, (x, y, half, cell))
|
|
2113
|
+
_paste_candidate_preview(sheet, draw, image, (x + half + 3, y, cell - half - 3, cell),
|
|
2114
|
+
dark=True)
|
|
2115
|
+
else:
|
|
2116
|
+
_paste_candidate_preview(sheet, draw, image, (x, y, cell, cell))
|
|
2117
|
+
except Exception:
|
|
2118
|
+
draw.text((x + 8, y + 8), 'unreadable', fill=(200, 0, 0))
|
|
2119
|
+
draw.rectangle([x, y, x + cell, y + cell], outline=(120, 120, 128))
|
|
2120
|
+
probe = candidate.get('probe') or {}
|
|
2121
|
+
alpha = candidate.get('effective_alpha_mean')
|
|
2122
|
+
if alpha is None:
|
|
2123
|
+
alpha = probe.get('alpha_mean')
|
|
2124
|
+
risk = (' a=%s w=%s' % (
|
|
2125
|
+
'?' if alpha is None else round(alpha),
|
|
2126
|
+
'?' if probe.get('near_white_ratio') is None
|
|
2127
|
+
else round(probe['near_white_ratio'] * 100),
|
|
2128
|
+
)) if _candidate_is_visual_risk(candidate) else ''
|
|
2129
|
+
draw.text((x + 2, y + cell + 3), '[%s] %s %dx%d%s' % (
|
|
2130
|
+
candidate['id'], candidate['file'], probe.get('w') or 0, probe.get('h') or 0, risk),
|
|
2131
|
+
fill=(20, 20, 24))
|
|
2132
|
+
first = (candidate.get('placements') or [{}])[0]
|
|
2133
|
+
total_placements = len(candidate.get('source_placements') or
|
|
2134
|
+
candidate.get('placements') or [])
|
|
2135
|
+
if first.get('slide'):
|
|
2136
|
+
box = first['box']
|
|
2137
|
+
draw.text((x + 2, y + cell + 18), 's%d @%d,%d %dx%d seen=%d' % (
|
|
2138
|
+
first['slide'], box[0], box[1], box[2], box[3],
|
|
2139
|
+
total_placements), fill=(20, 20, 24))
|
|
2140
|
+
else:
|
|
2141
|
+
draw.text((x + 2, y + cell + 18), 'layout x%d' % len(candidate.get('placements') or []),
|
|
2142
|
+
fill=(20, 20, 24))
|
|
2143
|
+
_save_visual_sheet(sheet, path)
|
|
2144
|
+
return path
|
|
2145
|
+
|
|
2146
|
+
|
|
2147
|
+
def emit_asset_vision_groups(outdir, candidates, slide_count, ldir):
|
|
2148
|
+
"""生成受预算约束的拼版和结构化索引,返回已选/未选组。"""
|
|
2149
|
+
groups = build_asset_vision_groups(candidates)
|
|
2150
|
+
selected, omitted = select_asset_vision_groups(groups, slide_count)
|
|
2151
|
+
_safe_remove(os.path.join(ldir, 'vision-group-*.jpg'))
|
|
2152
|
+
_safe_remove(os.path.join(ldir, 'contact-sheet-*.png'))
|
|
2153
|
+
_safe_remove(os.path.join(ldir, 'contact-sheet.png'))
|
|
2154
|
+
_safe_remove(os.path.join(ldir, 'asset-context-sheet-*.png'))
|
|
2155
|
+
|
|
2156
|
+
paths = []
|
|
2157
|
+
if not has_pillow():
|
|
2158
|
+
omitted = groups
|
|
2159
|
+
selected = []
|
|
2160
|
+
else:
|
|
2161
|
+
pages = sorted({page for group in selected for page in group['pages'] if page > 0})
|
|
2162
|
+
png_dir = render_asset_vision_pages(outdir, pages)
|
|
2163
|
+
try:
|
|
2164
|
+
for index, group in enumerate(selected, 1):
|
|
2165
|
+
path = os.path.join(ldir, 'vision-group-%d.jpg' % index)
|
|
2166
|
+
if asset_vision_group_sheet(outdir, group, png_dir, path):
|
|
2167
|
+
paths.append(path)
|
|
2168
|
+
group['sheet'] = os.path.basename(path)
|
|
2169
|
+
if selected and len(paths) != len(selected):
|
|
2170
|
+
raise RuntimeError('视觉判断拼版生成失败')
|
|
2171
|
+
except VisionContextError:
|
|
2172
|
+
raise
|
|
2173
|
+
except Exception:
|
|
2174
|
+
_safe_remove(os.path.join(ldir, 'vision-group-*.jpg'))
|
|
2175
|
+
selected, omitted, paths = [], groups, []
|
|
2176
|
+
if paths:
|
|
2177
|
+
# 旧流程只认 contact-sheet.png;保留首个视觉组的 PNG 别名,新的 BRIEF 不再要求读它。
|
|
2178
|
+
try:
|
|
2179
|
+
from PIL import Image
|
|
2180
|
+
legacy = os.path.join(ldir, 'contact-sheet-1.png')
|
|
2181
|
+
Image.open(paths[0]).convert('RGB').save(legacy, 'PNG', optimize=True)
|
|
2182
|
+
shutil.copy2(legacy, os.path.join(ldir, 'contact-sheet.png'))
|
|
2183
|
+
except Exception:
|
|
2184
|
+
pass
|
|
2185
|
+
|
|
2186
|
+
def serialize(group):
|
|
2187
|
+
return {
|
|
2188
|
+
'id': group['id'],
|
|
2189
|
+
'sheet': group.get('sheet'),
|
|
2190
|
+
'pages': group['pages'],
|
|
2191
|
+
'input_count': group['input_count'],
|
|
2192
|
+
'candidates': [{
|
|
2193
|
+
'id': candidate['id'],
|
|
2194
|
+
'source_media': candidate['file'],
|
|
2195
|
+
'source_px': [candidate['probe'].get('w'), candidate['probe'].get('h')],
|
|
2196
|
+
'bytes': candidate.get('bytes'),
|
|
2197
|
+
'repeat_count': candidate.get('n'),
|
|
2198
|
+
'fullscreen': candidate.get('fullscreen'),
|
|
2199
|
+
'effective_alpha_mean': candidate.get('effective_alpha_mean'),
|
|
2200
|
+
'near_white_ratio': candidate['probe'].get('near_white_ratio'),
|
|
2201
|
+
'placements': candidate.get('placements') or [],
|
|
2202
|
+
'source_placements': candidate.get('source_placements') or
|
|
2203
|
+
candidate.get('placements') or [],
|
|
2204
|
+
} for candidate in group['candidates']],
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2207
|
+
index = {
|
|
2208
|
+
'version': 2,
|
|
2209
|
+
'limits': {
|
|
2210
|
+
'single_page_image_budget': SINGLE_PAGE_IMAGE_BUDGET,
|
|
2211
|
+
'multi_page_image_budget': MULTI_PAGE_IMAGE_BUDGET,
|
|
2212
|
+
'pack_cap': VISUAL_PACK_CAP,
|
|
2213
|
+
'input_cap': VISUAL_INPUT_CAP,
|
|
2214
|
+
},
|
|
2215
|
+
'selected': [serialize(group) for group in selected],
|
|
2216
|
+
'omitted': [serialize(group) for group in omitted],
|
|
2217
|
+
}
|
|
2218
|
+
with open(os.path.join(ldir, 'asset-vision-groups.json'), 'w', encoding='utf-8') as stream:
|
|
2219
|
+
json.dump(index, stream, ensure_ascii=False, indent=2)
|
|
2220
|
+
stream.write('\n')
|
|
2221
|
+
return selected, omitted, paths
|
|
2222
|
+
|
|
2223
|
+
|
|
2224
|
+
def asset_context_sheets(outdir, cands, ldir):
|
|
2225
|
+
"""按候选主所在页去重拼整页语境,供模型识别 logo 墙和装饰用途。"""
|
|
2226
|
+
reviewed = [c for c in cands if needs_asset_judgment(c)]
|
|
2227
|
+
pages = []
|
|
2228
|
+
seen = set()
|
|
2229
|
+
for c in reviewed:
|
|
2230
|
+
page = next((no for no in c.get('slides') or [] if no and no != 9999), None)
|
|
2231
|
+
if page is not None and page not in seen:
|
|
2232
|
+
seen.add(page)
|
|
2233
|
+
pages.append(page)
|
|
2234
|
+
if not pages:
|
|
2235
|
+
return []
|
|
2236
|
+
import subprocess
|
|
2237
|
+
result = subprocess.run(
|
|
2238
|
+
[sys.executable, os.path.join(HERE, 'render_pages.py'), outdir,
|
|
2239
|
+
'--pages', 'slides', '--only', ','.join(map(str, pages)), '--no-html'],
|
|
2240
|
+
capture_output=True, text=True,
|
|
2241
|
+
)
|
|
2242
|
+
png_dir = os.path.join(outdir, 'ref', 'rebuild', 'png')
|
|
2243
|
+
if result.returncode or not os.path.isdir(png_dir):
|
|
2244
|
+
return []
|
|
2245
|
+
try:
|
|
2246
|
+
from PIL import Image, ImageDraw
|
|
2247
|
+
except Exception:
|
|
2248
|
+
return []
|
|
2249
|
+
paths = []
|
|
2250
|
+
candidate_ids = defaultdict(list)
|
|
2251
|
+
for index, c in enumerate(cands, 1):
|
|
2252
|
+
if not needs_asset_judgment(c):
|
|
2253
|
+
continue
|
|
2254
|
+
for page in c.get('slides') or []:
|
|
2255
|
+
if page in seen:
|
|
2256
|
+
candidate_ids[page].append(index)
|
|
2257
|
+
for start in range(0, len(pages), CONTEXT_BATCH):
|
|
2258
|
+
batch = pages[start:start + CONTEXT_BATCH]
|
|
2259
|
+
cols, cw, ch, pad, lab = 2, 480, 270, 16, 22
|
|
2260
|
+
rows = (len(batch) + cols - 1) // cols
|
|
2261
|
+
sheet = Image.new('RGB', (cols * (cw + pad) + pad,
|
|
2262
|
+
rows * (ch + pad + lab) + pad), (245, 245, 247))
|
|
2263
|
+
draw = ImageDraw.Draw(sheet)
|
|
2264
|
+
for offset, page in enumerate(batch):
|
|
2265
|
+
x = pad + (offset % cols) * (cw + pad)
|
|
2266
|
+
y = pad + (offset // cols) * (ch + pad + lab)
|
|
2267
|
+
source = os.path.join(png_dir, 'slide-%d.png' % page)
|
|
2268
|
+
if os.path.exists(source):
|
|
2269
|
+
image = Image.open(source).convert('RGB')
|
|
2270
|
+
image.thumbnail((cw, ch))
|
|
2271
|
+
sheet.paste(image, (x, y))
|
|
2272
|
+
draw.rectangle([x, y, x + cw, y + ch], outline=(120, 120, 128))
|
|
2273
|
+
draw.text((x + 2, y + ch + 5), 'slide %d candidates=%s'
|
|
2274
|
+
% (page, ','.join(map(str, candidate_ids[page]))),
|
|
2275
|
+
fill=(20, 20, 24))
|
|
2276
|
+
path = os.path.join(ldir, 'asset-context-sheet-%d.png'
|
|
2277
|
+
% (start // CONTEXT_BATCH + 1))
|
|
2278
|
+
sheet.save(path, optimize=True)
|
|
2279
|
+
paths.append(path)
|
|
2280
|
+
return paths
|
|
2281
|
+
|
|
2282
|
+
|
|
1566
2283
|
# ---------------------------------------------------------------- 落盘
|
|
1567
2284
|
def write(p, s):
|
|
1568
2285
|
with open(p, 'w', encoding='utf-8') as f:
|
|
1569
2286
|
f.write(s)
|
|
1570
2287
|
|
|
1571
2288
|
|
|
1572
|
-
def
|
|
2289
|
+
def bound_visual_candidates(candidates, archetypes):
|
|
2290
|
+
"""只把最终有图片槽的候选交给判断单;其余仍留在联系表供视觉核对。"""
|
|
2291
|
+
bound_files = {
|
|
2292
|
+
s.get('source_media')
|
|
2293
|
+
for a in archetypes
|
|
2294
|
+
for s in a.get('slots') or []
|
|
2295
|
+
if s.get('source_media')
|
|
2296
|
+
}
|
|
2297
|
+
return [c for c in candidates if c.get('file') in bound_files]
|
|
2298
|
+
|
|
2299
|
+
|
|
2300
|
+
def visual_slot_candidates(candidates, archetypes):
|
|
2301
|
+
"""把候选绑定到最终槽位;页面截图只作该槽位的视觉语境。"""
|
|
2302
|
+
source_candidates = {
|
|
2303
|
+
candidate.get('file'): candidate
|
|
2304
|
+
for candidate in bound_visual_candidates(candidates, archetypes)
|
|
2305
|
+
}
|
|
2306
|
+
rows, seen = [], set()
|
|
2307
|
+
for archetype in archetypes:
|
|
2308
|
+
pages = set(archetype.get('pages') or archetype.get('_sample_pages') or [])
|
|
2309
|
+
for slot in archetype.get('slots') or []:
|
|
2310
|
+
source = slot.get('source_media')
|
|
2311
|
+
raw_box = slot.get('box')
|
|
2312
|
+
candidate = source_candidates.get(source)
|
|
2313
|
+
if not pages or not candidate or not raw_box or not needs_asset_judgment(candidate):
|
|
2314
|
+
continue
|
|
2315
|
+
box = [round(value) for value in raw_box]
|
|
2316
|
+
key = (source, archetype['name'], tuple(box))
|
|
2317
|
+
if key in seen:
|
|
2318
|
+
continue
|
|
2319
|
+
seen.add(key)
|
|
2320
|
+
source_placements = candidate.get('placements') or []
|
|
2321
|
+
matching = [
|
|
2322
|
+
placement for placement in source_placements
|
|
2323
|
+
if tuple(placement.get('box') or ()) == tuple(box)
|
|
2324
|
+
and placement.get('slide') in pages
|
|
2325
|
+
]
|
|
2326
|
+
if not matching:
|
|
2327
|
+
continue
|
|
2328
|
+
slide = matching[0]['slide']
|
|
2329
|
+
row = dict(candidate)
|
|
2330
|
+
row['placements'] = [{
|
|
2331
|
+
'slide': slide,
|
|
2332
|
+
'box': box,
|
|
2333
|
+
'archetype': archetype['name'],
|
|
2334
|
+
}]
|
|
2335
|
+
row['slides'] = [slide] if slide else []
|
|
2336
|
+
row['source_placements'] = source_placements
|
|
2337
|
+
rows.append(row)
|
|
2338
|
+
return rows
|
|
2339
|
+
|
|
2340
|
+
|
|
2341
|
+
def emit_manifest(d, assets, vision_groups, ldir):
|
|
1573
2342
|
L = ['version: alpha',
|
|
1574
2343
|
'name: TODO-style-name # 英文 kebab,体现气质,不要用文件名',
|
|
1575
2344
|
'name_zh: TODO中文名',
|
|
@@ -1590,6 +2359,28 @@ def emit_manifest(d, assets, ldir):
|
|
|
1590
2359
|
L.append(' on-bg: %s' % (a.get('on_bg') or 'light'))
|
|
1591
2360
|
if a['use_full']:
|
|
1592
2361
|
L.append(' use_full: true')
|
|
2362
|
+
if vision_groups:
|
|
2363
|
+
L += [
|
|
2364
|
+
'asset_vision_groups:',
|
|
2365
|
+
' # 每项对应拼版中的一个候选实例;同源图在不同页型/位置可分别定性。',
|
|
2366
|
+
' # 取值与 FaaS 对齐:logo|slogan|background|texture|icon|decorative|illustration|photo|chart|screenshot|footer-copyright|page-number|watermark|content-image|unknown。',
|
|
2367
|
+
]
|
|
2368
|
+
for group in vision_groups:
|
|
2369
|
+
for candidate in group['candidates']:
|
|
2370
|
+
placement = (candidate.get('placements') or [{}])[0]
|
|
2371
|
+
L.append(' - id: %s' % candidate['id'])
|
|
2372
|
+
L.append(' source_media: %s' % q(candidate['file']))
|
|
2373
|
+
if placement.get('box'):
|
|
2374
|
+
L.append(' box: %s' % placement['box'])
|
|
2375
|
+
L.append(' visual_kind: TODO-visual-kind-%s # %s;视觉组 %s'
|
|
2376
|
+
% (candidate['id'], candidate['id'], group['id']))
|
|
2377
|
+
L += [
|
|
2378
|
+
'asset_decisions:',
|
|
2379
|
+
' # 仅在不在视觉预算内的图片、或需要覆盖已有判断时追加。',
|
|
2380
|
+
' # 位置例外写 box;同图同坐标跨页型不同,再补 layout。',
|
|
2381
|
+
' # - {source_media: example.png, visual_kind: chart}',
|
|
2382
|
+
' # - {source_media: example.png, layout: layout-2, box: [0, 0, 100, 100], visual_kind: decorative}',
|
|
2383
|
+
]
|
|
1593
2384
|
write(os.path.join(ldir, 'manifest.yaml'), '\n'.join(L) + '\n')
|
|
1594
2385
|
|
|
1595
2386
|
|
|
@@ -1686,6 +2477,11 @@ def draft_flow(a, facts, canvas):
|
|
|
1686
2477
|
cur.append(items[i + 1])
|
|
1687
2478
|
regions.append(cur)
|
|
1688
2479
|
|
|
2480
|
+
# 整页左右边距 = 所有内容的横向外包络,作为各区带的缺省。
|
|
2481
|
+
lefts = [s['box'][0] for s in items]
|
|
2482
|
+
rights = [s['box'][0] + s['box'][2] for s in items]
|
|
2483
|
+
page_margin = [min(lefts), cW - max(rights)]
|
|
2484
|
+
|
|
1689
2485
|
out = []
|
|
1690
2486
|
for reg in regions:
|
|
1691
2487
|
if not reg:
|
|
@@ -1708,8 +2504,17 @@ def draft_flow(a, facts, canvas):
|
|
|
1708
2504
|
if len(rows) > 1:
|
|
1709
2505
|
row_gap = round(rows[1][0]['box'][1]
|
|
1710
2506
|
- (rows[0][0]['box'][1] + rows[0][0]['box'][3]))
|
|
1711
|
-
|
|
1712
|
-
|
|
2507
|
+
region = {'kind': 'grid', 'cols': cols, 'gap': [max(col_gap, 0), max(row_gap, 0)],
|
|
2508
|
+
'items': rows[0]}
|
|
2509
|
+
# 卡片组的横向范围常和整页不同(标题贴左、卡片居中)。整页边距是所有元素的
|
|
2510
|
+
# 外包络,直接套给居中卡片组会把它拉偏成左对齐。区带范围和整页明显不一致时,
|
|
2511
|
+
# 落这个区带自己的左右边距,消费端把网格放进它再填 1fr。按落盘的整数比较,
|
|
2512
|
+
# 亚像素噪声不触发多余的区带边距。
|
|
2513
|
+
reg_margin = [min(s['box'][0] for s in rows[0]),
|
|
2514
|
+
cW - max(s['box'][0] + s['box'][2] for s in rows[0])]
|
|
2515
|
+
if [int(reg_margin[0]), int(reg_margin[1])] != [int(page_margin[0]), int(page_margin[1])]:
|
|
2516
|
+
region['margin'] = reg_margin
|
|
2517
|
+
out.append(region)
|
|
1713
2518
|
elif len(rows) == len(reg):
|
|
1714
2519
|
# 每行一个元素 = 真的竖着排
|
|
1715
2520
|
inner = 0
|
|
@@ -1724,9 +2529,7 @@ def draft_flow(a, facts, canvas):
|
|
|
1724
2529
|
out.append({'kind': 'free', 'items': fixed})
|
|
1725
2530
|
if len(out) < 2:
|
|
1726
2531
|
return None
|
|
1727
|
-
|
|
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)],
|
|
2532
|
+
return {'top': items[0]['box'][1], 'margin': page_margin,
|
|
1730
2533
|
'gap': round(cut), 'regions': out}
|
|
1731
2534
|
|
|
1732
2535
|
|
|
@@ -1875,7 +2678,66 @@ def structure_facts(archetypes, d, shapes):
|
|
|
1875
2678
|
return out, recipes
|
|
1876
2679
|
|
|
1877
2680
|
|
|
2681
|
+
LAYOUT_CONTROL_KEYS = {
|
|
2682
|
+
'names', 'roles', 'text_roles', 'layout_modes', 'bg_rules',
|
|
2683
|
+
}
|
|
2684
|
+
|
|
2685
|
+
|
|
2686
|
+
def emit_layout_controls(layout_lines, ldir, text_role_candidates, flow_archetypes):
|
|
2687
|
+
"""从兼容用的 layouts.yaml 分出模型只需编辑的判断区。
|
|
2688
|
+
|
|
2689
|
+
旧判断单把控制项和每个 slot 的坐标正文混在一起。模型为补一个角色读取整份文件,
|
|
2690
|
+
在版式很多的模板上会把时间耗在无须判断的数值上。仍保留旧文件给既有调用方;
|
|
2691
|
+
新文件只承载最终可覆盖它的五个顶层判断区。
|
|
2692
|
+
"""
|
|
2693
|
+
blocks, current = {}, None
|
|
2694
|
+
for line in layout_lines:
|
|
2695
|
+
match = re.match(r'^([A-Za-z_][\w-]*):', line)
|
|
2696
|
+
if match:
|
|
2697
|
+
current = match.group(1)
|
|
2698
|
+
if current in LAYOUT_CONTROL_KEYS:
|
|
2699
|
+
blocks[current] = [line]
|
|
2700
|
+
continue
|
|
2701
|
+
if current in blocks:
|
|
2702
|
+
blocks[current].append(line)
|
|
2703
|
+
|
|
2704
|
+
controls = [
|
|
2705
|
+
'# 版式判断控制区 —— 只读并编辑本文件;不要打开或修改 layouts.yaml。',
|
|
2706
|
+
'# package.py 会用本文件覆盖 layouts.yaml 的同名判断区,后者仅保留坐标事实与兼容输入。',
|
|
2707
|
+
'# 可编辑顶层键仅为 names / roles / text_roles / layout_modes / bg_rules。',
|
|
2708
|
+
]
|
|
2709
|
+
for key in ('names', 'roles'):
|
|
2710
|
+
if key in blocks:
|
|
2711
|
+
controls.extend([''] + blocks[key])
|
|
2712
|
+
if text_role_candidates:
|
|
2713
|
+
controls += [
|
|
2714
|
+
'',
|
|
2715
|
+
'text_roles:',
|
|
2716
|
+
'# 默认文字槽都是 body;仅把确认属于 title|subtitle|header|footer 的例外填为',
|
|
2717
|
+
'# <id>: title(不要给普通正文补 body)。候选对应的原始槽位在下列注释中。',
|
|
2718
|
+
]
|
|
2719
|
+
for role_id, slot in text_role_candidates:
|
|
2720
|
+
controls.append('# %s:%s' % (
|
|
2721
|
+
role_id, (slot.get('txt') or '(无样本文字)')[:60]))
|
|
2722
|
+
if flow_archetypes:
|
|
2723
|
+
controls += [
|
|
2724
|
+
'',
|
|
2725
|
+
'layout_modes:',
|
|
2726
|
+
'# 默认 slots。只有样张明确需要内容随高度重排时,取消注释并填 `<页型>: flow`。',
|
|
2727
|
+
]
|
|
2728
|
+
controls.extend('# %s: flow' % archetype['name'] for archetype in flow_archetypes)
|
|
2729
|
+
if 'bg_rules' in blocks:
|
|
2730
|
+
controls.extend([''] + blocks['bg_rules'])
|
|
2731
|
+
write(os.path.join(ldir, 'layout-controls.yaml'), '\n'.join(controls) + '\n')
|
|
2732
|
+
|
|
2733
|
+
|
|
1878
2734
|
def emit_layouts(archetypes, ldir, busy_hints=None, facts=None, recipes=None):
|
|
2735
|
+
sampled_archetypes = {
|
|
2736
|
+
id(archetype) for archetype in archetypes
|
|
2737
|
+
if (archetype.get('rep') is not None
|
|
2738
|
+
or archetype.get('pages')
|
|
2739
|
+
or archetype.get('_sample_pages'))
|
|
2740
|
+
}
|
|
1879
2741
|
prefilled = sum(1 for a in archetypes if a.get('zh'))
|
|
1880
2742
|
L = ['# 判断单草案 —— package.py 读它产出 layouts.md,deck 的版式坐标从 layouts.md 读。',
|
|
1881
2743
|
'# 只改 names / roles / text_roles / layout_modes / bg_rules 五段(都是扁平键值,'
|
|
@@ -1903,11 +2765,16 @@ def emit_layouts(archetypes, ldir, busy_hints=None, facts=None, recipes=None):
|
|
|
1903
2765
|
for a in need_role:
|
|
1904
2766
|
szs = sorted({round(s['sz']) for s in a['slots'] if s.get('sz')}, reverse=True)
|
|
1905
2767
|
L.append(' %s: TODO角色 # 代表页 %s,共 %d 页;文字块 %d 个,字号 %s;'
|
|
1906
|
-
'图片 %d 张%s'
|
|
2768
|
+
'图片 %d 张%s%s'
|
|
1907
2769
|
% (a['name'], a['rep'], len(a['pages']),
|
|
1908
2770
|
len([s for s in a['slots'] if not s.get('asset')]),
|
|
1909
2771
|
'/'.join(str(x) for x in szs[:5]) or '未声明',
|
|
1910
|
-
a.get('pic_n') or 0, ';有满屏底图' if a.get('bg_raw') else ''
|
|
2772
|
+
a.get('pic_n') or 0, ';有满屏底图' if a.get('bg_raw') else '',
|
|
2773
|
+
';末页候选,结合样张判断 closing 或实际角色'
|
|
2774
|
+
if a.get('_last_page_candidate') else ''))
|
|
2775
|
+
# 普通正文先保持 body:它是安全且可消费的默认值。标题/页眉/页脚的少量例外依然
|
|
2776
|
+
# 要由模型看样张后写入 text_roles;把每一个正文槽都做成 TODO 会迫使模型逐行复述
|
|
2777
|
+
# 近百个显然的 body,挤占真正的视觉判断时间。
|
|
1911
2778
|
text_role_ids = {}
|
|
1912
2779
|
for a in archetypes:
|
|
1913
2780
|
index = 0
|
|
@@ -1917,23 +2784,12 @@ def emit_layouts(archetypes, ldir, busy_hints=None, facts=None, recipes=None):
|
|
|
1917
2784
|
index += 1
|
|
1918
2785
|
text_role_ids[id(slot)] = '%s-text-%d' % (a['name'], index)
|
|
1919
2786
|
if text_role_ids:
|
|
1920
|
-
L.append('
|
|
1921
|
-
|
|
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 '未声明')))
|
|
2787
|
+
L.append('# 文字槽默认均为 body。看样张后,只把确实属于 title|subtitle|header|footer 的'
|
|
2788
|
+
'例外追加到 text_roles:;不要为普通正文逐条补 body。')
|
|
1932
2789
|
flow_archetypes = [a for a in archetypes if a.get('flow')]
|
|
1933
2790
|
if flow_archetypes:
|
|
1934
|
-
L.append('
|
|
1935
|
-
|
|
1936
|
-
L.append(' %s: TODO布局模式 # 依据见 layouts 段该页型上方的结构事实' % a['name'])
|
|
2791
|
+
L.append('# 同时有 flow 与 slots 时默认保留 slots,保证固定构图可消费。'
|
|
2792
|
+
'只有样张明确需要内容随高度重排时,才在 layout_modes: 中写 <页型>: flow。')
|
|
1937
2793
|
# 禁放区是**背景图**的属性,不是页型的属性——按背景资产分组,页型再多也不涨
|
|
1938
2794
|
bgs = []
|
|
1939
2795
|
for a in archetypes:
|
|
@@ -1962,10 +2818,18 @@ def emit_layouts(archetypes, ldir, busy_hints=None, facts=None, recipes=None):
|
|
|
1962
2818
|
y1 = max(b[1] + b[3] for b in boxes)
|
|
1963
2819
|
L.append(' text_safe: [%d, %d, %d, %d] # 由该背景各页型的槽位并集算出'
|
|
1964
2820
|
% (x0, y0, x1 - x0, y1 - y0))
|
|
1965
|
-
|
|
2821
|
+
elif any(id(archetype) in sampled_archetypes
|
|
2822
|
+
for archetype in archetypes if archetype['bg'] == bg):
|
|
1966
2823
|
L.append(' text_safe: TODO安全文字区[x,y,w,h](该背景下没有任何槽位可依据)')
|
|
1967
|
-
|
|
1968
|
-
|
|
2824
|
+
else:
|
|
2825
|
+
L.append(' text_safe: [0, 0, 0, 0] # 未见对应样张,没有可依据的文字区')
|
|
2826
|
+
if any(id(archetype) in sampled_archetypes
|
|
2827
|
+
for archetype in archetypes if archetype['bg'] == bg):
|
|
2828
|
+
L.append(' avoid: TODO禁放区列表;无禁放区写 [],有则写 [{box: [x,y,w,h], reason: "..."}]')
|
|
2829
|
+
L.append(' pairing_rule: "TODO这张背景上标题/正文/图表要避让哪些区域"')
|
|
2830
|
+
else:
|
|
2831
|
+
L.append(' avoid: [] # 未见对应样张,不额外推断禁放区')
|
|
2832
|
+
L.append(' pairing_rule: "未见对应样张;沿用该页型已有槽位"')
|
|
1969
2833
|
L.append('layouts:')
|
|
1970
2834
|
for a in archetypes:
|
|
1971
2835
|
fx = (facts or {}).get(a['name']) or {}
|
|
@@ -2010,6 +2874,10 @@ def emit_layouts(archetypes, ldir, busy_hints=None, facts=None, recipes=None):
|
|
|
2010
2874
|
L.append(' - kind: grid')
|
|
2011
2875
|
L.append(' cols: %d' % r['cols'])
|
|
2012
2876
|
L.append(' gap: [%d, %d]' % tuple(r['gap']))
|
|
2877
|
+
if r.get('margin'):
|
|
2878
|
+
L.append(' margin: [%d, %d] # 本区带自己的左右边距,'
|
|
2879
|
+
'和整页 margin 不同(居中卡片组不跟标题的左边距)'
|
|
2880
|
+
% tuple(r['margin']))
|
|
2013
2881
|
elif r['kind'] == 'free':
|
|
2014
2882
|
L.append(' - kind: free # 推不出规整结构,按 slots 的坐标摆')
|
|
2015
2883
|
else:
|
|
@@ -2037,6 +2905,9 @@ def emit_layouts(archetypes, ldir, busy_hints=None, facts=None, recipes=None):
|
|
|
2037
2905
|
extra += ', css: "%s"' % str(child['css']).replace('"', "'")
|
|
2038
2906
|
if child.get('asset'):
|
|
2039
2907
|
extra += ', asset: %s' % child['asset']
|
|
2908
|
+
if child.get('source_media'):
|
|
2909
|
+
extra += ', source_media: %s' % child['source_media']
|
|
2910
|
+
extra += ', source_box: %s' % child['box']
|
|
2040
2911
|
L.append(' - {role: %s, type: %s%s}'
|
|
2041
2912
|
% (child['role'], child['type'], extra))
|
|
2042
2913
|
continue
|
|
@@ -2055,6 +2926,10 @@ def emit_layouts(archetypes, ldir, busy_hints=None, facts=None, recipes=None):
|
|
|
2055
2926
|
extra += ', css: "%s"' % str(s['css']).replace('"', "'")
|
|
2056
2927
|
if s.get('asset'):
|
|
2057
2928
|
extra += ', asset: %s' % s['asset']
|
|
2929
|
+
if s.get('source_media'):
|
|
2930
|
+
extra += ', source_media: %s' % s['source_media']
|
|
2931
|
+
if r['kind'] != 'free':
|
|
2932
|
+
extra += ', source_box: %s' % s['box']
|
|
2058
2933
|
L.append(' - {role: %s, type: %s%s}' % (s['role'], s['type'], extra))
|
|
2059
2934
|
L.append(' slots:')
|
|
2060
2935
|
for s in a['slots']:
|
|
@@ -2064,6 +2939,8 @@ def emit_layouts(archetypes, ldir, busy_hints=None, facts=None, recipes=None):
|
|
|
2064
2939
|
extra = ''
|
|
2065
2940
|
if s.get('asset'):
|
|
2066
2941
|
extra += ', asset: %s' % s['asset']
|
|
2942
|
+
if s.get('source_media'):
|
|
2943
|
+
extra += ', source_media: %s' % s['source_media']
|
|
2067
2944
|
if s.get('css') is not None:
|
|
2068
2945
|
extra += ', css: "%s"' % str(s['css']).replace('"', "'")
|
|
2069
2946
|
L.append(' - {role: %s, box: %s, type: %s%s}'
|
|
@@ -2075,9 +2952,19 @@ def emit_layouts(archetypes, ldir, busy_hints=None, facts=None, recipes=None):
|
|
|
2075
2952
|
% (dcr['box'], dcr['geom'], dcr['css'].replace('"', "'")))
|
|
2076
2953
|
L.append(' confidence: %s' % a.get('confidence', 'medium'))
|
|
2077
2954
|
write(os.path.join(ldir, 'layouts.yaml'), '\n'.join(L) + '\n')
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2955
|
+
emit_layout_controls(L, ldir, [
|
|
2956
|
+
(text_role_ids[id(slot)], slot)
|
|
2957
|
+
for archetype in archetypes
|
|
2958
|
+
for slot in archetype.get('slots') or []
|
|
2959
|
+
if id(archetype) in sampled_archetypes and id(slot) in text_role_ids
|
|
2960
|
+
], [
|
|
2961
|
+
archetype for archetype in flow_archetypes
|
|
2962
|
+
if id(archetype) in sampled_archetypes
|
|
2963
|
+
])
|
|
2964
|
+
|
|
2965
|
+
|
|
2966
|
+
def emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, ldir,
|
|
2967
|
+
has_asset_candidates=False):
|
|
2081
2968
|
"""design.md 正文。
|
|
2082
2969
|
|
|
2083
2970
|
每条规则只出现一次——同一条散在 Fast Path / Usage / Background Safety /
|
|
@@ -2086,7 +2973,6 @@ def emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, l
|
|
|
2086
2973
|
"""
|
|
2087
2974
|
canvas = d['canvas']['px']
|
|
2088
2975
|
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)
|
|
2090
2976
|
imp, webs = import_line(fonts)
|
|
2091
2977
|
sidecar = '`layouts.md`'
|
|
2092
2978
|
|
|
@@ -2107,12 +2993,15 @@ def emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, l
|
|
|
2107
2993
|
% sidecar,
|
|
2108
2994
|
'3. **按页型给的形态落元素** —— 页型给 `flow` 就用流式,给 `slots` 就用绝对,'
|
|
2109
2995
|
'两者只会出现一个。'
|
|
2110
|
-
'**flow**:整块用一个纵向 flex 容器,`top` 是它的起始 y,`margin`
|
|
2996
|
+
'**flow**:整块用一个纵向 flex 容器,`top` 是它的起始 y,`margin` 是整块的左右边距,'
|
|
2111
2997
|
'`gap` 是区带之间的间距;`regions` 从上往下依次排,**每个区带的高度由它自己的'
|
|
2112
2998
|
'内容决定,不要写死高度**——上面的区带内容变多时,下面的自然被推下去,这正是'
|
|
2113
2999
|
'这套表达要解决的事。区带内部:`kind: grid` 用 `grid-template-columns: repeat(cols, 1fr)` '
|
|
2114
3000
|
'配 `gap: [行间距, 列间距]`;`kind: stack` 用纵向 flex 配 `gap`;`kind: free` '
|
|
2115
|
-
'按 item 自带的 `box`
|
|
3001
|
+
'按 item 自带的 `box` 绝对定位。区带自带 `margin: [左, 右]` 时用它的、'
|
|
3002
|
+
'覆盖整块的 `margin`(模板里居中的卡片组和贴左的标题横向范围本就不同);'
|
|
3003
|
+
'没带就用整块的 `margin`。`grid` 在自己这份左右边距里再 `repeat(cols, 1fr)`。'
|
|
3004
|
+
'`grid` 里的 `role: group` 是一张卡片:'
|
|
2116
3005
|
'group 的 `css` 用于外层容器,内部 `items` 按顺序纵向排布并使用 group 的 `gap`。'
|
|
2117
3006
|
'每个 `role: container` 的项是容器,把它的 `css` 逐项原样写进 style,内容放进去;'
|
|
2118
3007
|
'其中没有 `border-radius` 就按 `0`,不得自行补圆角。',
|
|
@@ -2134,7 +3023,7 @@ def emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, l
|
|
|
2134
3023
|
'7. **保持标题结构** —— 有合适页型可参考时,沿用该页型已有的标题层级与局部 '
|
|
2135
3024
|
'`css`;只渲染该页型已有的文字槽,背景中已经可见的固定标题不再创建文本,'
|
|
2136
3025
|
'页型没有 `subtitle` 槽就不新增副标题。没有合适参考时,按本包整体视觉组织标题。']
|
|
2137
|
-
if assets:
|
|
3026
|
+
if assets or has_asset_candidates:
|
|
2138
3027
|
L += ['', '资产文件(背景由页型的 `background` 字段指定,'
|
|
2139
3028
|
'图片资产的位置由该页型 `slots` 里带 `asset` 的槽给出):', '',
|
|
2140
3029
|
'{{ASSET_TABLE}}', '',
|
|
@@ -2168,15 +3057,7 @@ def emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, l
|
|
|
2168
3057
|
L.append('- 封面页铺满 `bg-cover`,整幅覆盖 %dx%d 画布。' % (canvas[0], canvas[1]))
|
|
2169
3058
|
if any(a['role'] == 'content' for a in assets):
|
|
2170
3059
|
L.append('- 内容页的背景由该页型的 `background` 字段指定,整幅铺满。')
|
|
2171
|
-
|
|
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 '(无)'))
|
|
3060
|
+
L.append('{{LOGO_RULES}}')
|
|
2180
3061
|
L += ['- 坐标、字号、色值、资产位置以 %s 为准;本文件的 Colors / Typography 是可用值的清单。'
|
|
2181
3062
|
% sidecar,
|
|
2182
3063
|
'- 强调色族以 Colors 和 %s 的 slot CSS 为主;必要时可以使用 Colors 之外的颜色,'
|
|
@@ -2200,9 +3081,20 @@ def emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, l
|
|
|
2200
3081
|
|
|
2201
3082
|
|
|
2202
3083
|
def emit_brief(d, ctx, ldir):
|
|
2203
|
-
(tokens, rest, fonts, roles, assets, rejected, todos, archetypes, cands,
|
|
2204
|
-
leftover, lsheet
|
|
3084
|
+
(tokens, rest, fonts, roles, assets, rejected, todos, archetypes, cands, sheets,
|
|
3085
|
+
selected_vision_groups, omitted_vision_groups, leftover, lsheet) = ctx
|
|
2205
3086
|
canvas = d['canvas']['px']
|
|
3087
|
+
def sample_pages(archetype):
|
|
3088
|
+
return archetype.get('pages') or archetype.get('_sample_pages') or []
|
|
3089
|
+
|
|
3090
|
+
sampled_archetypes = [
|
|
3091
|
+
archetype for archetype in archetypes
|
|
3092
|
+
if archetype.get('rep') is not None or sample_pages(archetype)
|
|
3093
|
+
]
|
|
3094
|
+
template_only_archetypes = [
|
|
3095
|
+
archetype for archetype in archetypes
|
|
3096
|
+
if archetype not in sampled_archetypes
|
|
3097
|
+
]
|
|
2206
3098
|
L = ['# 抽取简报(第 1/3 步产物;改完草案跑 package.py 出包)', '',
|
|
2207
3099
|
'源:`%s` 画布 %dx%d %d 页 / %d 版式 主题 %s form=%s'
|
|
2208
3100
|
% (d['source']['filename'], canvas[0], canvas[1], d['counts']['slides'],
|
|
@@ -2212,9 +3104,9 @@ def emit_brief(d, ctx, ldir):
|
|
|
2212
3104
|
# 待判断清单从草案实时扫 TODO 生成,不写死:写死的清单会和草案对不上——
|
|
2213
3105
|
# 既漏掉后加的段(模型读到一半才发现还有活),又在草案已预填时还催人去填。
|
|
2214
3106
|
HINT = {'manifest.yaml': '看两张图定气质',
|
|
2215
|
-
'
|
|
3107
|
+
'layout-controls.yaml': '看 layout-sheet.png;只改这个控制区',
|
|
2216
3108
|
'body.md': 'Colors 用途列草案已填好,觉得不对再改'}
|
|
2217
|
-
for fn in ('manifest.yaml', 'body.md', '
|
|
3109
|
+
for fn in ('manifest.yaml', 'body.md', 'layout-controls.yaml', 'frontmatter.yaml'):
|
|
2218
3110
|
path = os.path.join(ldir, fn)
|
|
2219
3111
|
if not os.path.exists(path):
|
|
2220
3112
|
continue
|
|
@@ -2237,21 +3129,34 @@ def emit_brief(d, ctx, ldir):
|
|
|
2237
3129
|
'(%s)' % hint if hint else ''))
|
|
2238
3130
|
for t in todos:
|
|
2239
3131
|
L.append('- ' + t)
|
|
2240
|
-
L += ['', '##
|
|
2241
|
-
'
|
|
2242
|
-
|
|
2243
|
-
|
|
3132
|
+
L += ['', '## 资产判断(按视觉组一次看完)', '',
|
|
3133
|
+
('视觉判断拼版:%s。每张都含候选独立卡与所在页截图;只读这些拼版,不逐张打开素材。'
|
|
3134
|
+
% '、'.join('`l-out/%s`' % os.path.basename(path) for path in sheets))
|
|
3135
|
+
if sheets else '(未生成视觉拼版;不要给图片候选定性,已按内容图保留位置并在 gaps 说明。)',
|
|
3136
|
+
'`l-out/asset-vision-groups.json` 记录每张候选的原图尺寸、所有页内位置和尺寸;'
|
|
3137
|
+
'透明/近白候选在拼版中同时给棋盘格和深灰底预览。',
|
|
3138
|
+
'按每个候选实例填 `asset_vision_groups.visual_kind`;同源图在不同页型/位置可不同。'
|
|
3139
|
+
'第三方 logo 墙属于 `content-image`,不是 deck 的 `logo`。',
|
|
3140
|
+
'',
|
|
3141
|
+
'| ID | 文件 | 原图 | 出现 | 页 | 所有位置 |', '|---|---|---|---|---|---|']
|
|
2244
3142
|
decided = {a['src']['file']: a['id'] for a in assets}
|
|
2245
3143
|
why = {c['file']: r for c, r in rejected}
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
3144
|
+
selected_candidates = {
|
|
3145
|
+
candidate['file']: candidate
|
|
3146
|
+
for group in selected_vision_groups
|
|
3147
|
+
for candidate in group['candidates']
|
|
3148
|
+
}
|
|
3149
|
+
for c in selected_candidates.values():
|
|
3150
|
+
L.append('| `%s` | `%s` | %sx%s | %d | %s | `%s` |' % (
|
|
3151
|
+
c['id'], c['file'], c['probe'].get('w') or '?', c['probe'].get('h') or '?',
|
|
3152
|
+
c['n'], ','.join(map(str, c['slides'])) or 'layout', _placement_text(c)))
|
|
3153
|
+
if omitted_vision_groups:
|
|
3154
|
+
omitted_pages = sorted({
|
|
3155
|
+
page for group in omitted_vision_groups for page in group['pages'] if page > 0
|
|
3156
|
+
})
|
|
2252
3157
|
L.append('')
|
|
2253
|
-
L.append('
|
|
2254
|
-
|
|
3158
|
+
L.append('未进视觉预算:%s;对应 slot 默认保留内容图片位置,不会自动升为风格资产。'
|
|
3159
|
+
% ('第%s页' % '、'.join(map(str, omitted_pages)) if omitted_pages else '版式候选'))
|
|
2255
3160
|
L += ['', '## 颜色(草案 token 已写进 frontmatter.yaml)', '',
|
|
2256
3161
|
'| token | hex | 出现 |', '|---|---|---|']
|
|
2257
3162
|
for name, r in tokens:
|
|
@@ -2267,24 +3172,55 @@ def emit_brief(d, ctx, ldir):
|
|
|
2267
3172
|
L.append('')
|
|
2268
3173
|
L.append('字号轴:' + '、'.join('%s=%dpx(n=%d)' % (k, round(v['sz_px']), v['n'])
|
|
2269
3174
|
for k, v in roles.items()))
|
|
2270
|
-
L += ['', '##
|
|
2271
|
-
'`l-out/layout-sheet.png` 是各页型代表页的重建图——**看它给页型起名**,'
|
|
2272
|
-
|
|
3175
|
+
L += ['', '## 版式聚类(判断项在 layout-controls.yaml,坐标事实在 layouts.yaml)', '',
|
|
3176
|
+
('`l-out/layout-sheet.png` 是各页型代表页的重建图——**看它给页型起名**,'
|
|
3177
|
+
'不用再逐页查 shapes。' if sampled_archetypes else
|
|
3178
|
+
'`l-out/layout-sheet.png` 是模板版式层的重建图;用它看整体视觉即可,'
|
|
3179
|
+
'没有对应样张的版式已按模板名称预填,不逐项改名或判角色。')
|
|
3180
|
+
if lsheet else '(未生成版式图,按下面的 slot 原文命名)', '',
|
|
2273
3181
|
'| archetype | 页数 | 代表页 | 背景 | slot 数 |', '|---|---|---|---|---|']
|
|
2274
|
-
for a in
|
|
3182
|
+
for a in sampled_archetypes:
|
|
3183
|
+
pages = sample_pages(a)
|
|
3184
|
+
representative = a.get('rep') or (pages[0] if pages else None)
|
|
2275
3185
|
L.append('| `%s` | %d | %s | %s | %d |' % (
|
|
2276
|
-
a['name'], len(
|
|
3186
|
+
a['name'], len(pages), representative, a['bg'] or '(无资产底图)', len(a['slots'])))
|
|
3187
|
+
if template_only_archetypes:
|
|
3188
|
+
L.append('')
|
|
3189
|
+
L.append('另有 %d 个模板声明版式没有对应样张:名称、角色和坐标已预填并会进入最终包;'
|
|
3190
|
+
'除非当前样张直接证明不对,不需要逐项判断。'
|
|
3191
|
+
% len(template_only_archetypes))
|
|
3192
|
+
sampled_pages = {
|
|
3193
|
+
page: archetype
|
|
3194
|
+
for archetype in sampled_archetypes
|
|
3195
|
+
for page in sample_pages(archetype)
|
|
3196
|
+
}
|
|
3197
|
+
first_page = 1
|
|
3198
|
+
last_page = d['counts']['slides']
|
|
3199
|
+
if first_page in sampled_pages:
|
|
3200
|
+
first_archetype = sampled_pages[first_page]
|
|
3201
|
+
L.append('')
|
|
3202
|
+
L.append('第 1 页实际使用页型:`%s`。若样张确为封面,只在 `roles.%s` 填 `cover`,'
|
|
3203
|
+
'不要按页型名称猜。'
|
|
3204
|
+
% (first_archetype['name'], first_archetype['name']))
|
|
3205
|
+
if last_page != first_page and last_page in sampled_pages:
|
|
3206
|
+
last_archetype = sampled_pages[last_page]
|
|
3207
|
+
L.append('第 %d 页实际使用页型:`%s`。若样张确为封底,只在 `roles.%s` 填 `closing`,'
|
|
3208
|
+
'不要按页型名称猜。'
|
|
3209
|
+
% (last_page, last_archetype['name'], last_archetype['name']))
|
|
2277
3210
|
if leftover:
|
|
2278
3211
|
L += ['', '未归入 archetype 的页:%s —— 都是单页孤例,需要就自己补一个 archetype。'
|
|
2279
3212
|
% ', '.join(map(str, leftover))]
|
|
2280
|
-
L += ['', '
|
|
2281
|
-
for a in
|
|
2282
|
-
|
|
3213
|
+
L += ['', '有样张页型的 slot 原文(据此起中文页型名,并在 text_roles 判断文本角色):', '']
|
|
3214
|
+
for a in sampled_archetypes:
|
|
3215
|
+
pages = sample_pages(a)
|
|
3216
|
+
representative = a.get('rep') or (pages[0] if pages else None)
|
|
3217
|
+
L.append('- `%s`(第 %s 页,覆盖 %s)' % (a['name'], representative, pages))
|
|
2283
3218
|
for s in a['slots']:
|
|
2284
3219
|
L.append(' - %s %spx 「%s」' % (s['role'], round(s['sz']), s['txt']))
|
|
2285
3220
|
L += ['', '## 下一步', '',
|
|
2286
|
-
'1.
|
|
2287
|
-
'2.
|
|
3221
|
+
'1. 并行看全部 `vision-group-*.jpg` 和 `layout-sheet.png`;'
|
|
3222
|
+
'2. 先填 asset_vision_groups,再用少量 asset_decisions 写例外,最后一次批量改完其它 TODO;'
|
|
3223
|
+
'3. 只改 `layout-controls.yaml` 的版式判断项,再跑 `package.py`。']
|
|
2288
3224
|
write(os.path.join(ldir, 'BRIEF.md'), '\n'.join(L) + '\n')
|
|
2289
3225
|
|
|
2290
3226
|
|
|
@@ -2302,50 +3238,22 @@ def main(argv=None):
|
|
|
2302
3238
|
cusage = color_usage(all_shapes, d)
|
|
2303
3239
|
tokens, rest, rows = draft_colors(d, cusage)
|
|
2304
3240
|
fonts = draft_fonts(d)
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
#
|
|
2308
|
-
#
|
|
2309
|
-
|
|
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)
|
|
3241
|
+
effective_alpha = fullscreen_effective_alpha(d, outdir, all_shapes)
|
|
3242
|
+
archetypes, pages, leftover = draft_layouts(d, outdir, effective_alpha)
|
|
3243
|
+
# 只有模板已声明 cover 页型时才能直读它的封面背景。首页和末页会单独保留样张,
|
|
3244
|
+
# 但它们的角色仍由模型看图判断,不能因为页码就自动升格为 cover / closing。
|
|
3245
|
+
cover_media = cover_background_media(archetypes)
|
|
2315
3246
|
exported_media = {m['media'] for m in d.get('media', []) if m.get('exported')}
|
|
2316
3247
|
bg_needed = {a['bg_raw'] for a in archetypes if a['bg_raw'] in exported_media}
|
|
2317
3248
|
bg_under = {p['no']: p.get('rendered_bg') or p['bg_media'] for p in pages}
|
|
2318
|
-
assets, rejected, todos, alias, pool = draft_assets(
|
|
3249
|
+
assets, rejected, todos, alias, pool = draft_assets(
|
|
3250
|
+
d, outdir, bg_needed, cover_media, bg_under, effective_alpha)
|
|
2319
3251
|
media_to_asset = {a['src']['media']: a['id'] for a in assets}
|
|
2320
3252
|
for m, w in (alias or {}).items():
|
|
2321
3253
|
if w in media_to_asset:
|
|
2322
3254
|
media_to_asset.setdefault(m, media_to_asset[w])
|
|
2323
3255
|
|
|
2324
|
-
# 版式里那些贴在装饰容器上的小图(图标托底圆里的图标之类):不进包的话,消费端只看到
|
|
2325
|
-
# 一个空圆,只能自己编图形。它们是版式的一部分,按 icon 收进来。
|
|
2326
|
-
ICON_CAP = 12
|
|
2327
|
-
ICON_BUDGET = 3 * 1024 * 1024 # 图标是小件,占包体不该超过背景
|
|
2328
3256
|
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 = []
|
|
2349
3257
|
for a in archetypes:
|
|
2350
3258
|
a['bg'] = media_to_asset.get(a['bg_raw'])
|
|
2351
3259
|
# 版式自带的图片元素:映射到资产 id。映射不到时**保留槽位但不写 asset**——
|
|
@@ -2356,22 +3264,25 @@ def main(argv=None):
|
|
|
2356
3264
|
if s.get('media'):
|
|
2357
3265
|
aid = media_to_asset.get(s['media'])
|
|
2358
3266
|
if not aid:
|
|
2359
|
-
s['
|
|
3267
|
+
c = pool.get(alias.get(s['media'], s['media'])) or pool.get(s['media'])
|
|
3268
|
+
s['role'] = 'asset-candidate'
|
|
3269
|
+
if c:
|
|
3270
|
+
s['source_media'] = c['file']
|
|
2360
3271
|
s.pop('media', None)
|
|
2361
|
-
dropped_slots.append((a['name'], s['box']))
|
|
2362
3272
|
keep.append(s)
|
|
2363
3273
|
continue
|
|
2364
3274
|
s['asset'] = aid
|
|
3275
|
+
c = pool.get(alias.get(s['media'], s['media'])) or pool.get(s['media'])
|
|
3276
|
+
if c:
|
|
3277
|
+
s['source_media'] = c['file']
|
|
2365
3278
|
# role 跟着资产走:图标槽写成 logo 会让消费端把它当品牌标识,每页都摆一个
|
|
2366
3279
|
s['role'] = next((x['kind'] for x in assets if x['id'] == aid), s['role'])
|
|
2367
3280
|
keep.append(s)
|
|
2368
3281
|
a['slots'] = keep
|
|
2369
3282
|
roles = draft_scale(d, archetypes)
|
|
2370
3283
|
slot_added = cover_slot_colors(tokens, archetypes, rows, cusage)
|
|
2371
|
-
#
|
|
2372
|
-
#
|
|
2373
|
-
# (n=1),按出现次数排序时排在最末——实测被 cands[:12] 截掉,模型于是把 bg-cover
|
|
2374
|
-
# 换成了已经在用的内容页背景,封面与内容页字节相同,封面主视觉整个丢失。
|
|
3284
|
+
# 局部图和半透明满屏叠加层必须结合页面语境定性。候选在本阶段按图片槽过滤:
|
|
3285
|
+
# 没有最终槽位的媒体无需让模型判断;有槽位但超出视觉预算的则保留通用 pic 槽。
|
|
2375
3286
|
decided_c = sorted([a['src'] for a in assets], key=lambda c: (-c['n'], c['file']))
|
|
2376
3287
|
other_c = sorted([c for c, _ in rejected], key=lambda c: (-c['n'], c['file']))
|
|
2377
3288
|
cands, seen_file = [], set()
|
|
@@ -2379,16 +3290,28 @@ def main(argv=None):
|
|
|
2379
3290
|
if c['file'] not in seen_file:
|
|
2380
3291
|
seen_file.add(c['file'])
|
|
2381
3292
|
cands.append(c)
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
3293
|
+
review_candidates = []
|
|
3294
|
+
for candidate_index, candidate in enumerate(
|
|
3295
|
+
visual_slot_candidates(cands, archetypes), 1):
|
|
3296
|
+
row = dict(candidate)
|
|
3297
|
+
row['id'] = 'asset-%d' % candidate_index
|
|
3298
|
+
review_candidates.append(row)
|
|
3299
|
+
selected_vision_groups, omitted_vision_groups, sheets = emit_asset_vision_groups(
|
|
3300
|
+
outdir, review_candidates, d['counts']['slides'], ldir)
|
|
2388
3301
|
lsheet = layout_sheet(outdir, archetypes, os.path.join(ldir, 'layout-sheet.png'))
|
|
2389
3302
|
|
|
2390
3303
|
anchors = draft_anchors(d, tokens, fonts, roles, assets, archetypes)
|
|
2391
3304
|
gaps, exceptions = [], []
|
|
3305
|
+
if omitted_vision_groups:
|
|
3306
|
+
omitted_pages = sorted({
|
|
3307
|
+
page for group in omitted_vision_groups for page in group['pages'] if page > 0
|
|
3308
|
+
})
|
|
3309
|
+
if omitted_pages:
|
|
3310
|
+
gaps.append('视觉略过:%s页' % '/'.join(map(str, omitted_pages)))
|
|
3311
|
+
else:
|
|
3312
|
+
gaps.append('视觉判断超预算,未覆盖版式候选')
|
|
3313
|
+
if review_candidates and not sheets:
|
|
3314
|
+
gaps.append('视觉拼版不可用,图片候选按内容图保留,未做风格定性。')
|
|
2392
3315
|
for c, why in rejected:
|
|
2393
3316
|
if '近全透明' in why:
|
|
2394
3317
|
gaps.append('母版/版式里的 %s 是%s,不是设计资产,任何情况下不要当背景用。' % (c['file'], why))
|
|
@@ -2407,10 +3330,6 @@ def main(argv=None):
|
|
|
2407
3330
|
gaps.append('%s%s按名额截断:普查到 %d 个,包内留了 %d 个%s。'
|
|
2408
3331
|
% (kind, at, e['total'], e['kept'],
|
|
2409
3332
|
';' + 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]))
|
|
2414
3333
|
# 「没命中映射表」不等于「装不上」:降级目标本身(Noto Sans SC 之类)和 Office 出厂体
|
|
2415
3334
|
# 都不在 match 列里,但它们本来就可用。真正危险的是**既没命中、又不是已知可用字体**的
|
|
2416
3335
|
# 那种——design.md 的字体栈里留着一个消费端装不上的商业字体名,且没有任何降级说明。
|
|
@@ -2438,7 +3357,7 @@ def main(argv=None):
|
|
|
2438
3357
|
exceptions.append('源 deck 第 %s 页是单页孤例,没有归纳成 archetype;需要类似构图时按最接近的页型改。'
|
|
2439
3358
|
% '、'.join(map(str, leftover)))
|
|
2440
3359
|
|
|
2441
|
-
emit_manifest(d, assets, ldir)
|
|
3360
|
+
emit_manifest(d, assets, selected_vision_groups, ldir)
|
|
2442
3361
|
emit_frontmatter(d, tokens, fonts, roles, anchors, gaps, ldir)
|
|
2443
3362
|
# 每张背景量一次局部对比度,作为「哪里不能压文字」的客观依据摆进判断单。
|
|
2444
3363
|
# 只报测到的数,不替人填 avoid——哪块算主体、要不要避让,是看图才能定的。
|
|
@@ -2453,16 +3372,17 @@ def main(argv=None):
|
|
|
2453
3372
|
for a in archetypes:
|
|
2454
3373
|
a['flow'] = draft_flow(a, facts.get(a['name']) or {}, (cW, cH))
|
|
2455
3374
|
emit_layouts(archetypes, ldir, busy_hints, facts, recipes)
|
|
2456
|
-
emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, ldir
|
|
2457
|
-
|
|
2458
|
-
|
|
3375
|
+
emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, ldir,
|
|
3376
|
+
has_asset_candidates=any(needs_asset_judgment(c) for c in cands))
|
|
3377
|
+
emit_brief(d, (tokens, rest, fonts, roles, assets, rejected, todos, archetypes, cands, sheets,
|
|
3378
|
+
selected_vision_groups, omitted_vision_groups, leftover, lsheet), ldir)
|
|
2459
3379
|
|
|
2460
3380
|
# 这几行落在模型判断「skill 是不是做完了」的那一刻。只报数就会被读成「包已生成」,
|
|
2461
3381
|
# 于是判断和打包整段被跳过,deck 拿不到任何版式坐标。所以这里报进度与下一条命令。
|
|
2462
3382
|
print('第 1/3 步完成,判断单草案 -> %s' % ldir)
|
|
2463
3383
|
print(' 待你确认:资产 %d(%s) 版式 %d 色 %d 字体 %d'
|
|
2464
3384
|
% (len(assets), ', '.join(x['id'] for x in assets), len(archetypes), len(tokens), len(fonts)))
|
|
2465
|
-
print(' 第 2 步 读 l-out/BRIEF.md
|
|
3385
|
+
print(' 第 2 步 读 l-out/BRIEF.md,并行看视觉组拼版与版式图;版式判断只改 layout-controls.yaml')
|
|
2466
3386
|
print(' 第 3 步 package.py 产出 design.md + layouts.md —— deck 的版式坐标只从这两份读')
|
|
2467
3387
|
sys.stdout.flush()
|
|
2468
3388
|
return 0
|