@lark-apaas/coding-steering 0.1.18-dev.4e64c13 → 0.1.18-dev.5c16901
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 +8 -5
- package/steering/design-html/skills/pptx-style-extract/scripts/check_v2.py +33 -3
- package/steering/design-html/skills/pptx-style-extract/scripts/draft.py +493 -103
- package/steering/design-html/skills/pptx-style-extract/scripts/extract.py +226 -6
- package/steering/design-html/skills/pptx-style-extract/scripts/package.py +167 -28
- package/steering/design-html/skills/pptx-style-extract/scripts/parts.py +3 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/query.py +3 -8
- package/steering/design-html/skills/pptx-style-extract/scripts/test_background_composite.py +57 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/test_color_contract.py +60 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/test_design_consumer_contract.py +62 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/test_flow_layout_contract.py +378 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/test_layout_css.py +98 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/test_rounded_contract.py +112 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/test_text_role_contract.py +168 -0
- package/steering/design-html/skills/pptx-style-extract/v2-format-spec.md +13 -6
- package/steering/design-html/skills/slide-deck/SKILL.md +15 -20
- package/steering/design-html/skills/slide-deck/scripts/check_local_references.py +179 -0
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
(package.py 见 TODO 即 FAIL),由 L 层改掉。
|
|
13
13
|
"""
|
|
14
14
|
import argparse
|
|
15
|
+
import copy
|
|
15
16
|
import json
|
|
16
17
|
import os
|
|
17
18
|
import re
|
|
@@ -28,6 +29,7 @@ OPAQUE_ENOUGH = 128 # 能当背景的最低不透明度:低于半透明就
|
|
|
28
29
|
# 那是叠加装饰不是背景
|
|
29
30
|
FILL_MANY = 5 # 「被大量当填充铺开」的次数下限,用于区分卡片底与偶发用色
|
|
30
31
|
BG_CONTENT_CAP = 5 # 内容页背景收几张:再多消费端也挑不过来,超出的写进 TODO 交人取舍
|
|
32
|
+
SHEET_CAP = 12 # 联系表展示上限;进包的资产不受它约束,一张都不截
|
|
31
33
|
|
|
32
34
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
33
35
|
SKILL_ROOT = os.path.dirname(HERE)
|
|
@@ -263,9 +265,9 @@ OFFICE_DEFAULT_FONTS_NORM = {norm(x) for x in OFFICE_DEFAULT_FONTS}
|
|
|
263
265
|
|
|
264
266
|
|
|
265
267
|
def cover_slot_colors(tokens, archetypes, rows, cusage):
|
|
266
|
-
"""slot 里出现的每个色值都必须在色板里有名字。
|
|
268
|
+
"""slot CSS 里出现的每个色值都必须在色板里有名字。
|
|
267
269
|
|
|
268
|
-
Hard Rules 写「颜色只用 colors 里的 token」,而 slot 的 color 是从模板直读的,
|
|
270
|
+
Hard Rules 写「颜色只用 colors 里的 token」,而 slot CSS 的 color 是从模板直读的,
|
|
269
271
|
两者不对齐就等于产物自己违反自己的规则——slot 的色值直读自模板,未必都已进
|
|
270
272
|
色板。这里把缺的补进色板,按用法归族命名。
|
|
271
273
|
"""
|
|
@@ -285,7 +287,7 @@ def cover_slot_colors(tokens, archetypes, rows, cusage):
|
|
|
285
287
|
added = []
|
|
286
288
|
for a in archetypes:
|
|
287
289
|
for s in a['slots']:
|
|
288
|
-
h = (s.get('
|
|
290
|
+
h = (s.get('_color') or '').upper()
|
|
289
291
|
if not h.startswith('#') or h in have:
|
|
290
292
|
continue
|
|
291
293
|
have.add(h)
|
|
@@ -499,7 +501,8 @@ def draft_anchors(d, tokens, fonts, roles, assets, archetypes):
|
|
|
499
501
|
# 6. 层级:字号跨度 + 字重是否单一(字重真单一才敢说「不靠字重」)
|
|
500
502
|
disp, body = roles.get('display'), roles.get('body')
|
|
501
503
|
if disp and body and disp['sz_px'] > body['sz_px']:
|
|
502
|
-
ws = {s.get('
|
|
504
|
+
ws = {s.get('_font_weight') for a in archetypes for s in a['slots']
|
|
505
|
+
if s.get('_font_weight')}
|
|
503
506
|
tail = (',字重只用 %s 一档' % list(ws)[0]) if len(ws) == 1 else ''
|
|
504
507
|
A.append(('size-driven-hierarchy', 'pattern',
|
|
505
508
|
'最大字号档与正文档相差 %.1f 倍(见 typography)%s'
|
|
@@ -894,11 +897,58 @@ def clean_layout_name(name):
|
|
|
894
897
|
return re.sub(r'^\d+[_\-\s]*', '', (name or '').strip()) or '未命名版式'
|
|
895
898
|
|
|
896
899
|
|
|
900
|
+
def is_bleed(s):
|
|
901
|
+
return (s.get('kind') == 'pic' and (s.get('w_pct') or 0) >= 95
|
|
902
|
+
and (s.get('h_pct') or 0) >= 95)
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
def top_bleed_media(shapes):
|
|
906
|
+
"""一串形状里最上层的满屏图。
|
|
907
|
+
|
|
908
|
+
OOXML 的 spTree 是绘制序,靠后的画在上面。一个版式常叠两张满屏图——通用底纹在
|
|
909
|
+
下、这一页的主视觉在上——所以看得见的是最后那张。取第一张会拿到底纹,实测让
|
|
910
|
+
章节页的深蓝主视觉被换成了另一张鲜蓝底纹,成品与原稿完全不是一个颜色。
|
|
911
|
+
"""
|
|
912
|
+
out = None
|
|
913
|
+
for s in shapes:
|
|
914
|
+
if is_bleed(s) and s.get('media'):
|
|
915
|
+
out = s['media']
|
|
916
|
+
return out
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
def slot_overlaps(slots):
|
|
920
|
+
"""同一页型里坐标互相重叠的槽对。只报事实,不改坐标——坐标是从模板量的。"""
|
|
921
|
+
out = []
|
|
922
|
+
for i in range(len(slots)):
|
|
923
|
+
for j in range(i + 1, len(slots)):
|
|
924
|
+
a, b = slots[i].get('box'), slots[j].get('box')
|
|
925
|
+
if not (a and b):
|
|
926
|
+
continue
|
|
927
|
+
ox = min(a[0] + a[2], b[0] + b[2]) - max(a[0], b[0])
|
|
928
|
+
oy = min(a[1] + a[3], b[1] + b[3]) - max(a[1], b[1])
|
|
929
|
+
if ox > 0 and oy > 0:
|
|
930
|
+
out.append('%s×%s 叠 %dx%d' % (slots[i].get('role'), slots[j].get('role'),
|
|
931
|
+
round(ox), round(oy)))
|
|
932
|
+
return out
|
|
933
|
+
|
|
934
|
+
|
|
935
|
+
def css_number(value, digits=3):
|
|
936
|
+
"""CSS 数值稳定格式:整数不带小数,其余去掉无意义尾零。"""
|
|
937
|
+
number = round(float(value), digits)
|
|
938
|
+
if number == int(number):
|
|
939
|
+
return str(int(number))
|
|
940
|
+
return ('%.*f' % (digits, number)).rstrip('0').rstrip('.')
|
|
941
|
+
|
|
942
|
+
|
|
897
943
|
def slot_style(s):
|
|
898
|
-
"""
|
|
944
|
+
"""占位符自带的排版样式,统一转成可直接写进 HTML style 的 CSS 声明串。
|
|
899
945
|
|
|
900
946
|
样式可能在三层:lstStyle.lvl1pPr(版式占位符常用)、段落 defRPr(Mac Office
|
|
901
947
|
导出把大量属性写在这一层)、段落 pPr(对齐)。逐层兜底,缺一层就往下取。
|
|
948
|
+
|
|
949
|
+
`box` 是布局几何,继续由 slot 独立承载;其余渲染属性不再泄漏成 size / color /
|
|
950
|
+
align / insets_px 等 PPTX 中间字段。下划线开头的键仅供 draft 内部统计,emit_layouts
|
|
951
|
+
不会写进消费者产物。
|
|
902
952
|
"""
|
|
903
953
|
txt = s.get('text') or {}
|
|
904
954
|
ls = dict((txt.get('lstStyle') or {}).get('lvl1pPr') or {})
|
|
@@ -919,25 +969,77 @@ def slot_style(s):
|
|
|
919
969
|
anysz = shape_sz(s)
|
|
920
970
|
if anysz:
|
|
921
971
|
ls['sz_px'] = anysz
|
|
972
|
+
body = txt.get('bodyPr') or {}
|
|
973
|
+
css = []
|
|
922
974
|
out = {}
|
|
975
|
+
insets = body.get('insets_px') or {}
|
|
976
|
+
if insets:
|
|
977
|
+
css.append('box-sizing: border-box')
|
|
978
|
+
css.append('padding: %spx %spx %spx %spx' % (
|
|
979
|
+
css_number(insets.get('tIns', 0) or 0),
|
|
980
|
+
css_number(insets.get('rIns', 0) or 0),
|
|
981
|
+
css_number(insets.get('bIns', 0) or 0),
|
|
982
|
+
css_number(insets.get('lIns', 0) or 0),
|
|
983
|
+
))
|
|
923
984
|
if ls.get('sz_px'):
|
|
924
|
-
|
|
985
|
+
size = round(ls['sz_px'])
|
|
986
|
+
css.append('font-size: %dpx' % size)
|
|
987
|
+
out['_font_size'] = size
|
|
988
|
+
weight = ls.get('weight') or (700 if ls.get('bold') else None)
|
|
989
|
+
if weight:
|
|
990
|
+
css.append('font-weight: %s' % weight)
|
|
991
|
+
out['_font_weight'] = weight
|
|
992
|
+
if ls.get('italic'):
|
|
993
|
+
css.append('font-style: italic')
|
|
994
|
+
decorations = []
|
|
995
|
+
if ls.get('underline'):
|
|
996
|
+
decorations.append('underline')
|
|
997
|
+
if ls.get('strike'):
|
|
998
|
+
decorations.append('line-through')
|
|
999
|
+
if decorations:
|
|
1000
|
+
css.append('text-decoration: %s' % ' '.join(decorations))
|
|
1001
|
+
if ls.get('spc_px') is not None:
|
|
1002
|
+
css.append('letter-spacing: %spx' % css_number(ls['spc_px']))
|
|
925
1003
|
col = (ls.get('color') or {}).get('resolved')
|
|
926
1004
|
if col:
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
1005
|
+
css.append('color: %s' % col)
|
|
1006
|
+
out['_color'] = col
|
|
1007
|
+
else:
|
|
1008
|
+
# 占位符的字色也可以是 gradFill(章节页的大号序号常这么做)。解析层已经把
|
|
1009
|
+
# stops 和角度记全了,这里只取单色就会整条丢掉,消费端只能自己编一个平色。
|
|
1010
|
+
# 与 decor 同一约定:css 是可直接写进 style 的声明串。
|
|
1011
|
+
f = ls.get('fill') or {}
|
|
1012
|
+
if f.get('type') == 'gradient':
|
|
1013
|
+
g = _load_query()._css_gradient(f)
|
|
1014
|
+
if g:
|
|
1015
|
+
css += ['background-image: %s' % g, '-webkit-background-clip: text',
|
|
1016
|
+
'background-clip: text', 'color: transparent']
|
|
1017
|
+
align = ls.get('algn')
|
|
1018
|
+
if align:
|
|
1019
|
+
css.append('text-align: %s' % {
|
|
1020
|
+
'l': 'left', 'ctr': 'center', 'r': 'right', 'just': 'justify',
|
|
1021
|
+
}.get(align, align))
|
|
1022
|
+
line_spacing = ls.get('lnSpc') or {}
|
|
1023
|
+
if line_spacing.get('mult'):
|
|
1024
|
+
css.append('line-height: %s' % css_number(line_spacing['mult'] * 1.2))
|
|
1025
|
+
elif line_spacing.get('px'):
|
|
1026
|
+
css.append('line-height: %spx' % css_number(line_spacing['px']))
|
|
1027
|
+
anchor = body.get('anchor')
|
|
935
1028
|
if anchor in ('ctr', 'b'):
|
|
936
|
-
|
|
1029
|
+
css += ['display: flex', 'flex-direction: column',
|
|
1030
|
+
'justify-content: %s' % {'ctr': 'center', 'b': 'flex-end'}[anchor]]
|
|
1031
|
+
if body.get('rot'):
|
|
1032
|
+
try:
|
|
1033
|
+
degrees = float(body['rot']) / 60000.0
|
|
1034
|
+
css.append('rotate: %sdeg' % css_number(degrees))
|
|
1035
|
+
except (TypeError, ValueError):
|
|
1036
|
+
pass
|
|
1037
|
+
if css:
|
|
1038
|
+
out['css'] = '; '.join(css)
|
|
937
1039
|
return out
|
|
938
1040
|
|
|
939
1041
|
|
|
940
|
-
def instance_override(shapes, slide_part, slots, bgm, cW, cH):
|
|
1042
|
+
def instance_override(shapes, slide_part, slots, bgm, cW, cH, composites=None):
|
|
941
1043
|
"""实例页覆盖版式:版式是骨架,实例页才是设计师最终摆定的样子。
|
|
942
1044
|
|
|
943
1045
|
版式底图常是多个版式共用的通用底纹,实例页可能另铺主视觉大图;标题占位符的框高
|
|
@@ -947,11 +1049,7 @@ def instance_override(shapes, slide_part, slots, bgm, cW, cH):
|
|
|
947
1049
|
ins = [s for s in shapes if s.get('part') == slide_part]
|
|
948
1050
|
if not ins:
|
|
949
1051
|
return slots, bgm
|
|
950
|
-
|
|
951
|
-
if (s.get('kind') == 'pic' and s.get('media')
|
|
952
|
-
and s.get('w_pct', 0) >= 95 and s.get('h_pct', 0) >= 95):
|
|
953
|
-
bgm = s['media']
|
|
954
|
-
break
|
|
1052
|
+
bgm = (composites or {}).get(slide_part) or top_bleed_media(ins) or bgm
|
|
955
1053
|
texts = []
|
|
956
1054
|
for s in ins:
|
|
957
1055
|
b = s.get('box') or {}
|
|
@@ -979,13 +1077,14 @@ def layouts_from_template(d, shapes, cW, cH):
|
|
|
979
1077
|
"""
|
|
980
1078
|
by_part = defaultdict(list)
|
|
981
1079
|
for s in shapes:
|
|
982
|
-
if s.get('layer') == 'layout' and s.get('
|
|
1080
|
+
if (s.get('layer') == 'layout' and s.get('kind') == 'sp'
|
|
1081
|
+
and (s.get('box') or {}).get('w') and (s.get('ph') or shape_text(s))):
|
|
983
1082
|
by_part[s['part']].append(s)
|
|
984
1083
|
bg_of_layout = {}
|
|
1084
|
+
composites = d.get('background_composites') or {}
|
|
985
1085
|
for s in shapes:
|
|
986
|
-
if
|
|
987
|
-
|
|
988
|
-
bg_of_layout.setdefault(s['part'], s.get('media'))
|
|
1086
|
+
if s.get('layer') == 'layout' and is_bleed(s) and s.get('media'):
|
|
1087
|
+
bg_of_layout[s['part']] = s['media'] # 靠后者在上层,最后一张才是看得见的
|
|
989
1088
|
topo = d.get('theme_topology') or {}
|
|
990
1089
|
theme_of_master = {m['master']: m.get('theme_label')
|
|
991
1090
|
for m in (topo.get('per_master') or [])}
|
|
@@ -1008,10 +1107,10 @@ def layouts_from_template(d, shapes, cW, cH):
|
|
|
1008
1107
|
slots, seen_kind = [], set()
|
|
1009
1108
|
for s in phs:
|
|
1010
1109
|
t = PH_TO_TYPE.get((s['ph'] or {}).get('type'), 'body')
|
|
1011
|
-
if t in ('slide-number', 'footer'):
|
|
1012
|
-
continue #
|
|
1110
|
+
if t in ('slide-number', 'footer') and not shape_text(s):
|
|
1111
|
+
continue # 空 chrome 占位符不是实际元素
|
|
1013
1112
|
b = s['box']
|
|
1014
|
-
role = t if t in ('title', 'subtitle') else 'body'
|
|
1113
|
+
role = t if t in ('title', 'subtitle', 'footer', 'slide-number') else 'body'
|
|
1015
1114
|
if t == 'title' and 'title' in seen_kind:
|
|
1016
1115
|
role, t = 'subtitle', 'subtitle'
|
|
1017
1116
|
seen_kind.add(t)
|
|
@@ -1020,10 +1119,18 @@ def layouts_from_template(d, shapes, cW, cH):
|
|
|
1020
1119
|
round(b.get('w', 0)), round(b.get('h', 0))],
|
|
1021
1120
|
'txt': shape_text(s) or (s.get('name') or '')[:24]}
|
|
1022
1121
|
row.update(slot_style(s))
|
|
1122
|
+
if t == 'body':
|
|
1123
|
+
ph = s.get('ph') or {}
|
|
1124
|
+
row.update({
|
|
1125
|
+
'_needs_role': True,
|
|
1126
|
+
'_source_layer': 'layout',
|
|
1127
|
+
'_placeholder': '%s/%s' % (
|
|
1128
|
+
ph.get('type') or '-', ph.get('idx') or '-'),
|
|
1129
|
+
})
|
|
1023
1130
|
slots.append(row)
|
|
1024
1131
|
# 非满屏的图片元素(logo / 联名标 / 装饰)——它们逐版式换位置换尺寸,
|
|
1025
1132
|
# 必须按版式落进 slots,压成一条全局「固定位」规则就会撞标题。
|
|
1026
|
-
bgm = bg_of_layout.get(l['part'])
|
|
1133
|
+
bgm = composites.get(l['part']) or bg_of_layout.get(l['part'])
|
|
1027
1134
|
for s in shapes:
|
|
1028
1135
|
if s['part'] != l['part'] or s.get('kind') != 'pic' or not s.get('media'):
|
|
1029
1136
|
continue
|
|
@@ -1040,7 +1147,8 @@ def layouts_from_template(d, shapes, cW, cH):
|
|
|
1040
1147
|
continue
|
|
1041
1148
|
inst = slide_of_layout.get(l['part'])
|
|
1042
1149
|
if inst:
|
|
1043
|
-
slots, bgm = instance_override(
|
|
1150
|
+
slots, bgm = instance_override(
|
|
1151
|
+
shapes, inst, slots, bgm, cW, cH, composites)
|
|
1044
1152
|
taken = {tuple(s['box']) for s in slots}
|
|
1045
1153
|
decor = collect_decor(shapes, inst or l['part'], taken, (cW, cH))
|
|
1046
1154
|
named_role = role_of_name(l.get('name'))
|
|
@@ -1138,17 +1246,76 @@ def collect_decor(shapes, part, taken_boxes, canvas, limit=10):
|
|
|
1138
1246
|
return out[:limit] # 同款不同位置都要留,位置本身是版式信息
|
|
1139
1247
|
|
|
1140
1248
|
|
|
1249
|
+
def placeholder_key(shape):
|
|
1250
|
+
ph = shape.get('ph') or {}
|
|
1251
|
+
if not ph:
|
|
1252
|
+
return None
|
|
1253
|
+
return (ph.get('type') or 'body', str(ph.get('idx') or ''))
|
|
1254
|
+
|
|
1255
|
+
|
|
1256
|
+
def merge_dict(base, override):
|
|
1257
|
+
"""把实例页的非空声明叠到版式声明上;空实例占位符继续继承版式事实。"""
|
|
1258
|
+
out = copy.deepcopy(base or {})
|
|
1259
|
+
for key, value in (override or {}).items():
|
|
1260
|
+
if value is None or value == []:
|
|
1261
|
+
continue
|
|
1262
|
+
if isinstance(value, dict) and isinstance(out.get(key), dict):
|
|
1263
|
+
out[key] = merge_dict(out[key], value)
|
|
1264
|
+
else:
|
|
1265
|
+
out[key] = copy.deepcopy(value)
|
|
1266
|
+
return out
|
|
1267
|
+
|
|
1268
|
+
|
|
1269
|
+
def inherited_text_shapes(layout_shapes, slide_shapes):
|
|
1270
|
+
"""返回实例页可用的文字形状,并补齐其引用版式中的占位符几何与样式。"""
|
|
1271
|
+
layout_text = []
|
|
1272
|
+
for shape in layout_shapes:
|
|
1273
|
+
if shape.get('kind') != 'sp' or not (shape.get('box') or {}).get('w'):
|
|
1274
|
+
continue
|
|
1275
|
+
ph = shape.get('ph') or {}
|
|
1276
|
+
ph_type = ph.get('type')
|
|
1277
|
+
if shape_text(shape) or (ph and ph_type not in ('ftr', 'dt', 'sldNum')):
|
|
1278
|
+
layout_text.append(shape)
|
|
1279
|
+
by_placeholder = {placeholder_key(s): s for s in layout_text if placeholder_key(s)}
|
|
1280
|
+
used = set()
|
|
1281
|
+
out = []
|
|
1282
|
+
for shape in slide_shapes:
|
|
1283
|
+
if shape.get('kind') != 'sp':
|
|
1284
|
+
continue
|
|
1285
|
+
key = placeholder_key(shape)
|
|
1286
|
+
base = by_placeholder.get(key)
|
|
1287
|
+
if base:
|
|
1288
|
+
merged = merge_dict(base, shape)
|
|
1289
|
+
merged['text'] = merge_dict(base.get('text'), shape.get('text'))
|
|
1290
|
+
if not shape_text(shape):
|
|
1291
|
+
merged['text']['paragraphs'] = copy.deepcopy(
|
|
1292
|
+
(base.get('text') or {}).get('paragraphs') or [])
|
|
1293
|
+
used.add(key)
|
|
1294
|
+
out.append((merged, 'slide+layout'))
|
|
1295
|
+
elif (shape.get('box') or {}).get('w') and shape_text(shape):
|
|
1296
|
+
out.append((shape, 'slide'))
|
|
1297
|
+
for shape in layout_text:
|
|
1298
|
+
key = placeholder_key(shape)
|
|
1299
|
+
if key not in used:
|
|
1300
|
+
out.append((shape, 'layout'))
|
|
1301
|
+
return out
|
|
1302
|
+
|
|
1303
|
+
|
|
1141
1304
|
def draft_layouts(d, outdir):
|
|
1142
|
-
|
|
1305
|
+
with open(os.path.join(outdir, 'ref', 'shapes.json'), encoding='utf-8') as stream:
|
|
1306
|
+
shapes = json.load(stream)['shapes']
|
|
1143
1307
|
cW, cH = d['canvas']['px']
|
|
1144
1308
|
if (d.get('form_hint') or {}).get('form') == 3:
|
|
1145
1309
|
arch = layouts_from_template(d, shapes, cW, cH)
|
|
1146
1310
|
if len(arch) >= 3:
|
|
1147
1311
|
return arch, [], []
|
|
1148
1312
|
by_slide = defaultdict(list)
|
|
1313
|
+
by_layout = defaultdict(list)
|
|
1149
1314
|
for s in shapes:
|
|
1150
1315
|
if s.get('layer') == 'slide':
|
|
1151
1316
|
by_slide[s['part']].append(s)
|
|
1317
|
+
elif s.get('layer') == 'layout':
|
|
1318
|
+
by_layout[s['part']].append(s)
|
|
1152
1319
|
|
|
1153
1320
|
bg_of_slide, layout_of_slide = {}, {}
|
|
1154
1321
|
for s in d.get('slides', []):
|
|
@@ -1156,40 +1323,51 @@ def draft_layouts(d, outdir):
|
|
|
1156
1323
|
bg_of_slide[s['part']] = json.dumps(bg, sort_keys=True) if isinstance(bg, dict) else bg
|
|
1157
1324
|
layout_of_slide[s['part']] = s.get('layout')
|
|
1158
1325
|
# 版式层的满屏底图(form=2 常态:底图挂在 layout 上)
|
|
1326
|
+
composites = d.get('background_composites') or {}
|
|
1159
1327
|
bg_of_layout = {}
|
|
1160
1328
|
for s in shapes:
|
|
1161
|
-
if
|
|
1162
|
-
|
|
1163
|
-
bg_of_layout.setdefault(s['part'], s.get('media'))
|
|
1329
|
+
if s.get('layer') == 'layout' and is_bleed(s) and s.get('media'):
|
|
1330
|
+
bg_of_layout[s['part']] = s['media']
|
|
1164
1331
|
|
|
1165
1332
|
pages = []
|
|
1166
1333
|
for part, sh in sorted(by_slide.items(), key=lambda kv: slide_no(kv[0])):
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
bg_media = s.get('media')
|
|
1171
|
-
break
|
|
1334
|
+
layout_part = layout_of_slide.get(part)
|
|
1335
|
+
layout_shapes = by_layout.get(layout_part) or []
|
|
1336
|
+
bg_media = top_bleed_media(sh)
|
|
1172
1337
|
if bg_media is None:
|
|
1173
|
-
bg_media = bg_of_layout.get(
|
|
1338
|
+
bg_media = bg_of_layout.get(layout_part)
|
|
1339
|
+
rendered_bg = (composites.get(part)
|
|
1340
|
+
or composites.get(layout_part)
|
|
1341
|
+
or bg_media)
|
|
1174
1342
|
texts = []
|
|
1175
|
-
for s in sh:
|
|
1176
|
-
|
|
1177
|
-
continue
|
|
1178
|
-
txt = shape_text(s)
|
|
1179
|
-
if not txt:
|
|
1180
|
-
continue
|
|
1343
|
+
for s, source_layer in inherited_text_shapes(layout_shapes, sh):
|
|
1344
|
+
txt = shape_text(s) or (s.get('name') or '')[:24]
|
|
1181
1345
|
b = s.get('box') or {}
|
|
1182
1346
|
if b.get('w', 0) < DECOR_MIN or b.get('h', 0) < 16:
|
|
1183
1347
|
continue
|
|
1184
|
-
|
|
1348
|
+
ph = s.get('ph') or {}
|
|
1349
|
+
ph_type = ph.get('type')
|
|
1350
|
+
direct_type = PH_TO_TYPE.get(ph_type, 'body')
|
|
1351
|
+
texts.append({
|
|
1352
|
+
'sz': shape_sz(s),
|
|
1353
|
+
'box': b,
|
|
1354
|
+
'txt': txt,
|
|
1355
|
+
'style': slot_style(s),
|
|
1356
|
+
'direct_type': direct_type,
|
|
1357
|
+
'needs_role': direct_type == 'body',
|
|
1358
|
+
'source_layer': source_layer,
|
|
1359
|
+
'placeholder': '%s/%s' % (ph_type or '-', ph.get('idx') or '-'),
|
|
1360
|
+
})
|
|
1185
1361
|
texts.sort(key=lambda t: (-t['sz'], t['box'].get('y', 0)))
|
|
1186
|
-
|
|
1362
|
+
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]
|
|
1187
1364
|
# 小图元素(logo / 角标 / 装饰)逐页记位置,供 archetype 落 slots
|
|
1188
1365
|
marks = [{'media': s['media'], 'box': s['box']} for s in pics
|
|
1189
1366
|
if s.get('media') and (s.get('box') or {}).get('w') and s.get('w_pct', 0) < 30]
|
|
1190
1367
|
pages.append({'part': part, 'no': slide_no(part), 'bg_media': bg_media,
|
|
1368
|
+
'rendered_bg': rendered_bg,
|
|
1191
1369
|
'bg_color': bg_of_slide.get(part), 'texts': texts, 'pic_n': len(pics),
|
|
1192
|
-
'marks': marks, 'shape_n': len(
|
|
1370
|
+
'marks': marks, 'shape_n': len(visible_shapes), 'layout': layout_part})
|
|
1193
1371
|
|
|
1194
1372
|
# 页型的**角色**(封面 / 章节页 / 内容页……)不在这里判:那是看图才能下的结论,
|
|
1195
1373
|
# 交给读得到重建图的模型。脚本只做客观归并——同一张底图 + 文字块数量相近的页
|
|
@@ -1227,6 +1405,9 @@ def draft_layouts(d, outdir):
|
|
|
1227
1405
|
rep = max(ps, key=lambda p: len(p['texts']))
|
|
1228
1406
|
if bg_raw == '__first__':
|
|
1229
1407
|
bg_raw = rep['bg_media'] or rep['bg_color'] or 'none'
|
|
1408
|
+
rendered_bg = rep.get('rendered_bg')
|
|
1409
|
+
if rendered_bg:
|
|
1410
|
+
bg_raw = rendered_bg
|
|
1230
1411
|
name = 'layout-%d' % gi
|
|
1231
1412
|
# 标题按「位置 + 跨度」认,不按字号——big-number 类的巨号数值常比标题还大
|
|
1232
1413
|
# 标题 = 该页最靠上的那批文本里最宽的一块。不按「画布前 28%」这类固定比例切:
|
|
@@ -1240,12 +1421,13 @@ def draft_layouts(d, outdir):
|
|
|
1240
1421
|
rest.sort(key=lambda t: (t['box'].get('y', 0), t['box'].get('x', 0)))
|
|
1241
1422
|
ordered = ([title] if title else []) + rest
|
|
1242
1423
|
slots = []
|
|
1243
|
-
|
|
1244
|
-
'需要更多同类槽时,按已有同类槽的间距等距延续,不要另起一套网格'
|
|
1245
|
-
'——包里的坐标是模板量出来的,自创网格等于放弃这套版式', name)
|
|
1246
|
-
for i, t in enumerate(ordered[:6]):
|
|
1424
|
+
for i, t in enumerate(ordered):
|
|
1247
1425
|
b = t['box']
|
|
1248
|
-
if t
|
|
1426
|
+
if t.get('needs_role'):
|
|
1427
|
+
role = typ = 'body'
|
|
1428
|
+
elif t.get('direct_type') in ('title', 'subtitle', 'footer', 'slide-number'):
|
|
1429
|
+
role = typ = t['direct_type']
|
|
1430
|
+
elif t is title:
|
|
1249
1431
|
role = typ = 'title'
|
|
1250
1432
|
elif (title and i == 1
|
|
1251
1433
|
# 副标题 = 紧跟在标题下方、与标题左对齐的那一块。三个量都相对标题
|
|
@@ -1261,6 +1443,12 @@ def draft_layouts(d, outdir):
|
|
|
1261
1443
|
round(b.get('w', 0)), round(b.get('h', 0))],
|
|
1262
1444
|
'type': typ, 'sz': t['sz'], 'txt': t['txt']}
|
|
1263
1445
|
row.update(t.get('style') or {})
|
|
1446
|
+
if t.get('needs_role'):
|
|
1447
|
+
row.update({
|
|
1448
|
+
'_needs_role': True,
|
|
1449
|
+
'_source_layer': t.get('source_layer'),
|
|
1450
|
+
'_placeholder': t.get('placeholder'),
|
|
1451
|
+
})
|
|
1264
1452
|
slots.append(row)
|
|
1265
1453
|
# 代表页上的小图元素按位置去重后落 slots(同一 logo 在不同页型位置不同)
|
|
1266
1454
|
seen_mark = set()
|
|
@@ -1274,7 +1462,15 @@ def draft_layouts(d, outdir):
|
|
|
1274
1462
|
'media': mk['media'],
|
|
1275
1463
|
'box': [round(b.get('x', 0)), round(b.get('y', 0)),
|
|
1276
1464
|
round(b.get('w', 0)), round(b.get('h', 0))]})
|
|
1277
|
-
|
|
1465
|
+
taken = {tuple(s['box']) for s in slots}
|
|
1466
|
+
decor = []
|
|
1467
|
+
seen_decor = set()
|
|
1468
|
+
for source_part in (rep.get('layout'), rep['part']):
|
|
1469
|
+
for item in collect_decor(shapes, source_part, taken, (cW, cH)):
|
|
1470
|
+
key = (tuple(item['box']), item['geom'], item['css'])
|
|
1471
|
+
if key not in seen_decor:
|
|
1472
|
+
seen_decor.add(key)
|
|
1473
|
+
decor.append(item)
|
|
1278
1474
|
archetypes.append({'name': name, 'bg': None, 'bg_raw': bg_raw, 'slots': slots,
|
|
1279
1475
|
'decor': decor,
|
|
1280
1476
|
'pages': sorted(p['no'] for p in ps), 'rep': rep['no'],
|
|
@@ -1337,7 +1533,7 @@ def contact_sheet(outdir, cands, path):
|
|
|
1337
1533
|
except Exception:
|
|
1338
1534
|
return None
|
|
1339
1535
|
cell, pad, cols = 220, 20, 4
|
|
1340
|
-
items = cands
|
|
1536
|
+
items = cands # 上限由调用方定,编号与 BRIEF 表格一一对应
|
|
1341
1537
|
if not items:
|
|
1342
1538
|
return None
|
|
1343
1539
|
rows = (len(items) + cols - 1) // cols
|
|
@@ -1424,10 +1620,11 @@ def emit_frontmatter(d, tokens, fonts, roles, anchors, gaps, ldir):
|
|
|
1424
1620
|
L.append('spacing:')
|
|
1425
1621
|
L.append(' page-padding: {top: %s, right: %s, bottom: %s, left: %s}'
|
|
1426
1622
|
% (edge['top'], edge['right'], edge['bottom'], edge['left']))
|
|
1427
|
-
#
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1623
|
+
# rounded.card 是全局 token,只能表达全档共同的一档圆角。多个非零档位或零/非零
|
|
1624
|
+
# 混用时,圆角属于 layouts.md 里的局部形状事实,压成一个值会把直角容器也圆角化。
|
|
1625
|
+
radii = d.get('radii_census') or []
|
|
1626
|
+
if len(radii) == 1 and radii[0]['px'] >= 1:
|
|
1627
|
+
top = radii[0]
|
|
1431
1628
|
L.append('rounded:')
|
|
1432
1629
|
L.append(' card: %dpx' % round(top['px']))
|
|
1433
1630
|
if edges_full:
|
|
@@ -1463,9 +1660,13 @@ def draft_flow(a, facts, canvas):
|
|
|
1463
1660
|
cW, cH = canvas
|
|
1464
1661
|
# 装饰件也算进来:很多模板的版式层只有几个占位符,真正撑起版面的是卡片容器
|
|
1465
1662
|
# (在 decor 里)。只看 slots 会把一页的主体结构整个漏掉。
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1663
|
+
slots = [s for s in a['slots'] if s.get('box')]
|
|
1664
|
+
fixed_roles = {'logo', 'slide-number', 'page-number', 'header', 'footer'}
|
|
1665
|
+
fixed = [s for s in slots if s.get('role') in fixed_roles]
|
|
1666
|
+
content_slots = [s for s in slots if s.get('role') not in fixed_roles]
|
|
1667
|
+
containers = [{'role': 'container', 'type': 'decor', 'box': dcr['box'],
|
|
1668
|
+
'css': dcr.get('css')} for dcr in (a.get('decor') or [])]
|
|
1669
|
+
items = group_flow_cards(content_slots, containers)
|
|
1469
1670
|
if len(items) < 2:
|
|
1470
1671
|
return None
|
|
1471
1672
|
items.sort(key=lambda s: (s['box'][1], s['box'][0]))
|
|
@@ -1476,7 +1677,7 @@ def draft_flow(a, facts, canvas):
|
|
|
1476
1677
|
return None
|
|
1477
1678
|
# 区带边界 = 间距分布里的最大空档。同一区带内部的间距(网格行距之类)总是明显
|
|
1478
1679
|
# 小于区带之间的间距,用本页自己的分布切,不设固定阈值。
|
|
1479
|
-
cut = _gap_cut(pos, min(pos), max(pos)) if len(pos) > 1 else
|
|
1680
|
+
cut = _gap_cut(pos, min(pos), max(pos)) if len(pos) > 1 else pos[0]
|
|
1480
1681
|
regions, cur = [], [items[0]]
|
|
1481
1682
|
for i, g in enumerate(gaps):
|
|
1482
1683
|
if g >= cut:
|
|
@@ -1519,6 +1720,8 @@ def draft_flow(a, facts, canvas):
|
|
|
1519
1720
|
# 每行元素数不一致(比如左列两张、右列一张跨两行)。硬说成 stack 会让消费端
|
|
1520
1721
|
# 以为它们是竖排的,比不给还糟。如实说这块推不出规整结构,按坐标摆。
|
|
1521
1722
|
out.append({'kind': 'free', 'items': reg})
|
|
1723
|
+
if fixed:
|
|
1724
|
+
out.append({'kind': 'free', 'items': fixed})
|
|
1522
1725
|
if len(out) < 2:
|
|
1523
1726
|
return None
|
|
1524
1727
|
lefts = [s['box'][0] for s in items]
|
|
@@ -1527,6 +1730,83 @@ def draft_flow(a, facts, canvas):
|
|
|
1527
1730
|
'gap': round(cut), 'regions': out}
|
|
1528
1731
|
|
|
1529
1732
|
|
|
1733
|
+
def box_contains(outer, inner):
|
|
1734
|
+
return (outer[0] <= inner[0] and outer[1] <= inner[1]
|
|
1735
|
+
and outer[0] + outer[2] >= inner[0] + inner[2]
|
|
1736
|
+
and outer[1] + outer[3] >= inner[1] + inner[3])
|
|
1737
|
+
|
|
1738
|
+
|
|
1739
|
+
def boxes_overlap(a, b):
|
|
1740
|
+
return (min(a[0] + a[2], b[0] + b[2]) > max(a[0], b[0])
|
|
1741
|
+
and min(a[1] + a[3], b[1] + b[3]) > max(a[1], b[1]))
|
|
1742
|
+
|
|
1743
|
+
|
|
1744
|
+
def overlap_ratio(outer, inner):
|
|
1745
|
+
width = min(outer[0] + outer[2], inner[0] + inner[2]) - max(outer[0], inner[0])
|
|
1746
|
+
height = min(outer[1] + outer[3], inner[1] + inner[3]) - max(outer[1], inner[1])
|
|
1747
|
+
if width <= 0 or height <= 0 or inner[2] <= 0 or inner[3] <= 0:
|
|
1748
|
+
return 0
|
|
1749
|
+
return width * height / (inner[2] * inner[3])
|
|
1750
|
+
|
|
1751
|
+
|
|
1752
|
+
def group_flow_cards(slots, containers):
|
|
1753
|
+
"""把并列卡片容器及其文字组成一层 group,避免拍平成多列元素。"""
|
|
1754
|
+
candidates = []
|
|
1755
|
+
for container in containers:
|
|
1756
|
+
children = [slot for slot in slots if box_contains(container['box'], slot['box'])]
|
|
1757
|
+
if len(children) >= 2:
|
|
1758
|
+
candidates.append((container, children))
|
|
1759
|
+
selected = []
|
|
1760
|
+
for container, children in sorted(
|
|
1761
|
+
candidates, key=lambda pair: pair[0]['box'][2] * pair[0]['box'][3]):
|
|
1762
|
+
if not any(boxes_overlap(container['box'], other['box']) for other, _ in selected):
|
|
1763
|
+
selected.append((container, children))
|
|
1764
|
+
if len(selected) < 2:
|
|
1765
|
+
return slots + containers
|
|
1766
|
+
|
|
1767
|
+
grouped_slots = {id(slot) for _, children in selected for slot in children}
|
|
1768
|
+
nested_by_container = {}
|
|
1769
|
+
for container, _ in selected:
|
|
1770
|
+
nested_by_container[id(container)] = [
|
|
1771
|
+
other for other in containers
|
|
1772
|
+
if other is not container and overlap_ratio(container['box'], other['box']) >= 0.9
|
|
1773
|
+
]
|
|
1774
|
+
grouped_containers = {
|
|
1775
|
+
id(container)
|
|
1776
|
+
for container, _ in selected
|
|
1777
|
+
for container in [container] + nested_by_container[id(container)]
|
|
1778
|
+
}
|
|
1779
|
+
out = [slot for slot in slots if id(slot) not in grouped_slots]
|
|
1780
|
+
out += [container for container in containers if id(container) not in grouped_containers]
|
|
1781
|
+
for container, children in selected:
|
|
1782
|
+
children = children + nested_by_container[id(container)]
|
|
1783
|
+
children = sorted(children, key=lambda slot: (slot['box'][1], slot['box'][0]))
|
|
1784
|
+
gaps = [children[i + 1]['box'][1]
|
|
1785
|
+
- (children[i]['box'][1] + children[i]['box'][3])
|
|
1786
|
+
for i in range(len(children) - 1)]
|
|
1787
|
+
outer = container['box']
|
|
1788
|
+
insets = [
|
|
1789
|
+
min(child['box'][1] - outer[1] for child in children),
|
|
1790
|
+
min(outer[0] + outer[2] - child['box'][0] - child['box'][2] for child in children),
|
|
1791
|
+
min(outer[1] + outer[3] - child['box'][1] - child['box'][3] for child in children),
|
|
1792
|
+
min(child['box'][0] - outer[0] for child in children),
|
|
1793
|
+
]
|
|
1794
|
+
padding = max(0, round(min(insets)))
|
|
1795
|
+
css = container.get('css') or ''
|
|
1796
|
+
if padding:
|
|
1797
|
+
css = '; '.join(part for part in (
|
|
1798
|
+
css.rstrip('; '), 'box-sizing: border-box', 'padding: %dpx' % padding) if part)
|
|
1799
|
+
out.append({
|
|
1800
|
+
'role': 'group',
|
|
1801
|
+
'type': 'group',
|
|
1802
|
+
'box': outer,
|
|
1803
|
+
'css': css,
|
|
1804
|
+
'gap': max(0, round(min(gaps))) if gaps else 0,
|
|
1805
|
+
'items': children,
|
|
1806
|
+
})
|
|
1807
|
+
return out
|
|
1808
|
+
|
|
1809
|
+
|
|
1530
1810
|
def structure_facts(archetypes, d, shapes):
|
|
1531
1811
|
"""每个页型的**结构事实**:栅格、垂直间距序列、容器样式配方、样张里的实际字数。
|
|
1532
1812
|
|
|
@@ -1555,8 +1835,7 @@ def structure_facts(archetypes, d, shapes):
|
|
|
1555
1835
|
'fill': fill, 'line': line, 'fx': fx, 'shapes': set()})
|
|
1556
1836
|
g['n'] += 1
|
|
1557
1837
|
g['parts'].add(s.get('part'))
|
|
1558
|
-
|
|
1559
|
-
g['radii'].append(s['radius_px'])
|
|
1838
|
+
g['radii'].append(s.get('radius_px') or 0)
|
|
1560
1839
|
g['shapes'].add(id(s))
|
|
1561
1840
|
ranked = sorted(groups.values(), key=lambda g: -g['n'])
|
|
1562
1841
|
recipe_id = {}
|
|
@@ -1598,7 +1877,9 @@ def structure_facts(archetypes, d, shapes):
|
|
|
1598
1877
|
|
|
1599
1878
|
def emit_layouts(archetypes, ldir, busy_hints=None, facts=None, recipes=None):
|
|
1600
1879
|
prefilled = sum(1 for a in archetypes if a.get('zh'))
|
|
1601
|
-
L = ['#
|
|
1880
|
+
L = ['# 判断单草案 —— package.py 读它产出 layouts.md,deck 的版式坐标从 layouts.md 读。',
|
|
1881
|
+
'# 只改 names / roles / text_roles / layout_modes / bg_rules 五段(都是扁平键值,'
|
|
1882
|
+
'改完 package.py 自动并回各页型)。',
|
|
1602
1883
|
'# 下面 layouts 段是普查数值,一个字都不要动——改它容易连带删掉 slots/confidence。']
|
|
1603
1884
|
if prefilled:
|
|
1604
1885
|
L.append('# names 已按模板自带的版式名填好 %d 条,读一遍确认表意即可,通常不用改。' % prefilled)
|
|
@@ -1627,6 +1908,32 @@ def emit_layouts(archetypes, ldir, busy_hints=None, facts=None, recipes=None):
|
|
|
1627
1908
|
len([s for s in a['slots'] if not s.get('asset')]),
|
|
1628
1909
|
'/'.join(str(x) for x in szs[:5]) or '未声明',
|
|
1629
1910
|
a.get('pic_n') or 0, ';有满屏底图' if a.get('bg_raw') else ''))
|
|
1911
|
+
text_role_ids = {}
|
|
1912
|
+
for a in archetypes:
|
|
1913
|
+
index = 0
|
|
1914
|
+
for slot in a.get('slots') or []:
|
|
1915
|
+
if not slot.get('_needs_role'):
|
|
1916
|
+
continue
|
|
1917
|
+
index += 1
|
|
1918
|
+
text_role_ids[id(slot)] = '%s-text-%d' % (a['name'], index)
|
|
1919
|
+
if text_role_ids:
|
|
1920
|
+
L.append('text_roles: # 取值 title|subtitle|header|footer|body;只改角色,不删槽')
|
|
1921
|
+
for a in archetypes:
|
|
1922
|
+
for slot in a.get('slots') or []:
|
|
1923
|
+
role_id = text_role_ids.get(id(slot))
|
|
1924
|
+
if not role_id:
|
|
1925
|
+
continue
|
|
1926
|
+
L.append(' %s: TODO文本角色 # 来源 %s;占位符 %s;样例 %s;'
|
|
1927
|
+
'box %s;字号 %s;css %s'
|
|
1928
|
+
% (role_id, slot.get('_source_layer') or '-',
|
|
1929
|
+
slot.get('_placeholder') or '-', q(slot.get('txt') or ''),
|
|
1930
|
+
slot.get('box'), round(slot.get('sz') or 0),
|
|
1931
|
+
q(slot.get('css') or '未声明')))
|
|
1932
|
+
flow_archetypes = [a for a in archetypes if a.get('flow')]
|
|
1933
|
+
if flow_archetypes:
|
|
1934
|
+
L.append('layout_modes: # 取值 flow|slots;内容会变的内容页优先 flow,固定构图页用 slots')
|
|
1935
|
+
for a in flow_archetypes:
|
|
1936
|
+
L.append(' %s: TODO布局模式 # 依据见 layouts 段该页型上方的结构事实' % a['name'])
|
|
1630
1937
|
# 禁放区是**背景图**的属性,不是页型的属性——按背景资产分组,页型再多也不涨
|
|
1631
1938
|
bgs = []
|
|
1632
1939
|
for a in archetypes:
|
|
@@ -1679,6 +1986,13 @@ def emit_layouts(archetypes, ldir, busy_hints=None, facts=None, recipes=None):
|
|
|
1679
1986
|
% '、'.join('%s=%d字' % (b, n) for b, n in fx['chars'][:6]))
|
|
1680
1987
|
if fx.get('recipes'):
|
|
1681
1988
|
L.append(' # 命中配方:%s' % '、'.join(fx['recipes'][:4]))
|
|
1989
|
+
# 槽与槽在坐标上重叠:PPT 里占位符互相压是常态(文字 valign 居中、样张只有一行,
|
|
1990
|
+
# 看不出来),照抄坐标做成 HTML 后内容一变长就撞。实测封面 title 框比 subtitle
|
|
1991
|
+
# 的顶还低 41px,两行标题直接压在副标题上。这里只报事实,怎么让开由你定。
|
|
1992
|
+
ov = slot_overlaps(a.get('slots') or [])
|
|
1993
|
+
if ov:
|
|
1994
|
+
L.append(' # 槽位重叠:%s(模板里靠文字居中不显形,内容变长会撞)'
|
|
1995
|
+
% '、'.join(ov[:3]))
|
|
1682
1996
|
L.append(' %s:' % a['name'])
|
|
1683
1997
|
if a.get('role'):
|
|
1684
1998
|
L.append(' role: %s' % a['role'])
|
|
@@ -1686,8 +2000,6 @@ def emit_layouts(archetypes, ldir, busy_hints=None, facts=None, recipes=None):
|
|
|
1686
2000
|
L.append(' background: %s' % a['bg'])
|
|
1687
2001
|
fl = a.get('flow')
|
|
1688
2002
|
if fl:
|
|
1689
|
-
L.append(' # ↓ flow 与 slots 二选一:内容长度会变的页用 flow(区带依次排、'
|
|
1690
|
-
'高度由内容定、下面的自动被推下去),构图固定的页用 slots。删掉不要的那个。')
|
|
1691
2003
|
L.append(' flow:')
|
|
1692
2004
|
L.append(' top: %d' % fl['top'])
|
|
1693
2005
|
L.append(' margin: [%d, %d]' % tuple(fl['margin']))
|
|
@@ -1705,6 +2017,32 @@ def emit_layouts(archetypes, ldir, busy_hints=None, facts=None, recipes=None):
|
|
|
1705
2017
|
L.append(' gap: %d' % r['gap'])
|
|
1706
2018
|
L.append(' items:')
|
|
1707
2019
|
for s in r['items']:
|
|
2020
|
+
if s.get('type') == 'group':
|
|
2021
|
+
L.append(' - role: group')
|
|
2022
|
+
L.append(' gap: %d' % s['gap'])
|
|
2023
|
+
if s.get('css'):
|
|
2024
|
+
L.append(' css: "%s"'
|
|
2025
|
+
% str(s['css']).replace('"', "'"))
|
|
2026
|
+
L.append(' items:')
|
|
2027
|
+
for child in s['items']:
|
|
2028
|
+
role_id = text_role_ids.get(id(child))
|
|
2029
|
+
if role_id:
|
|
2030
|
+
L.append(' # text-role: %s' % role_id)
|
|
2031
|
+
if child.get('type') == 'decor':
|
|
2032
|
+
L.append(' - {role: container, css: "%s"}'
|
|
2033
|
+
% str(child.get('css') or '').replace('"', "'"))
|
|
2034
|
+
continue
|
|
2035
|
+
extra = ''
|
|
2036
|
+
if child.get('css') is not None:
|
|
2037
|
+
extra += ', css: "%s"' % str(child['css']).replace('"', "'")
|
|
2038
|
+
if child.get('asset'):
|
|
2039
|
+
extra += ', asset: %s' % child['asset']
|
|
2040
|
+
L.append(' - {role: %s, type: %s%s}'
|
|
2041
|
+
% (child['role'], child['type'], extra))
|
|
2042
|
+
continue
|
|
2043
|
+
role_id = text_role_ids.get(id(s))
|
|
2044
|
+
if role_id:
|
|
2045
|
+
L.append(' # text-role: %s' % role_id)
|
|
1708
2046
|
# free 区带按坐标摆,而 slots 会被删掉,所以坐标必须写在这里
|
|
1709
2047
|
bx = ', box: %s' % s['box'] if r['kind'] == 'free' else ''
|
|
1710
2048
|
if s.get('type') == 'decor':
|
|
@@ -1712,22 +2050,22 @@ def emit_layouts(archetypes, ldir, busy_hints=None, facts=None, recipes=None):
|
|
|
1712
2050
|
% (bx, (s.get('css') or '').replace('"', "'")))
|
|
1713
2051
|
continue
|
|
1714
2052
|
extra = bx
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
extra += ', %s: %s' % (k, '"%s"' % s[k] if k == 'color' else s[k])
|
|
2053
|
+
if s.get('css') is not None:
|
|
2054
|
+
# CSS 串一律加引号:里面的逗号/冒号在 flow map 里是分隔符
|
|
2055
|
+
extra += ', css: "%s"' % str(s['css']).replace('"', "'")
|
|
1719
2056
|
if s.get('asset'):
|
|
1720
2057
|
extra += ', asset: %s' % s['asset']
|
|
1721
2058
|
L.append(' - {role: %s, type: %s%s}' % (s['role'], s['type'], extra))
|
|
1722
2059
|
L.append(' slots:')
|
|
1723
2060
|
for s in a['slots']:
|
|
2061
|
+
role_id = text_role_ids.get(id(s))
|
|
2062
|
+
if role_id:
|
|
2063
|
+
L.append(' # text-role: %s' % role_id)
|
|
1724
2064
|
extra = ''
|
|
1725
2065
|
if s.get('asset'):
|
|
1726
2066
|
extra += ', asset: %s' % s['asset']
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
v = s[k]
|
|
1730
|
-
extra += ', %s: %s' % (k, '"%s"' % v if k == 'color' else v)
|
|
2067
|
+
if s.get('css') is not None:
|
|
2068
|
+
extra += ', css: "%s"' % str(s['css']).replace('"', "'")
|
|
1731
2069
|
L.append(' - {role: %s, box: %s, type: %s%s}'
|
|
1732
2070
|
% (s['role'], s['box'], s['type'], extra))
|
|
1733
2071
|
if a.get('decor'):
|
|
@@ -1754,7 +2092,7 @@ def emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, l
|
|
|
1754
2092
|
|
|
1755
2093
|
L = ['## Overview', '',
|
|
1756
2094
|
'TODO: 两三句话讲清这套模板的性格与适用场景——看过联系表和页面重建图之后再写。', '']
|
|
1757
|
-
L.append(('模板自带 %d
|
|
2095
|
+
L.append(('模板自带 %d 种版式,页型、坐标和 CSS 样式都直读自版式层。'
|
|
1758
2096
|
% len(archetypes)) if (d.get('form_hint') or {}).get('form') == 3 else
|
|
1759
2097
|
('%d 页样张归纳出 %d 种页型。' % (d['counts']['slides'], len(archetypes))))
|
|
1760
2098
|
L += ['', '## Usage', '',
|
|
@@ -1774,22 +2112,35 @@ def emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, l
|
|
|
1774
2112
|
'内容决定,不要写死高度**——上面的区带内容变多时,下面的自然被推下去,这正是'
|
|
1775
2113
|
'这套表达要解决的事。区带内部:`kind: grid` 用 `grid-template-columns: repeat(cols, 1fr)` '
|
|
1776
2114
|
'配 `gap: [行间距, 列间距]`;`kind: stack` 用纵向 flex 配 `gap`;`kind: free` '
|
|
1777
|
-
'
|
|
1778
|
-
'
|
|
2115
|
+
'按 item 自带的 `box` 绝对定位。`grid` 里的 `role: group` 是一张卡片:'
|
|
2116
|
+
'group 的 `css` 用于外层容器,内部 `items` 按顺序纵向排布并使用 group 的 `gap`。'
|
|
2117
|
+
'每个 `role: container` 的项是容器,把它的 `css` 逐项原样写进 style,内容放进去;'
|
|
2118
|
+
'其中没有 `border-radius` 就按 `0`,不得自行补圆角。',
|
|
1779
2119
|
'4. **按 slot 落元素(页型给的是 slots 时)** —— 每个 slot 渲染成一个绝对定位元素:`box` 是 '
|
|
1780
|
-
'`[x, y, w, h]`(%dx%d
|
|
1781
|
-
'
|
|
2120
|
+
'`[x, y, w, h]`(%dx%d 画布上的绝对像素),机械展开成 `left/top/width/height`;'
|
|
2121
|
+
'slot 的 `css` 是模板排版属性已转译好的声明串,原样写进 style,不要另选字号、'
|
|
2122
|
+
'内边距、颜色或对齐。'
|
|
1782
2123
|
'带 `asset` 的 slot 是图片元素(logo、角标),把该资产放在它自己的 `box` 里;'
|
|
1783
2124
|
'这个页型没有 `asset` 槽,这一页就不出现该资产。' % (canvas[0], canvas[1]),
|
|
1784
2125
|
'5. **铺装饰几何** —— 页型的 `decor` 是这一页的图形骨架(图标托底的圆、'
|
|
1785
|
-
'卡片、分隔线):每条渲染成一个绝对定位空元素,`box` 给位置,`css`
|
|
1786
|
-
'`geom: ellipse` 另加
|
|
2126
|
+
'卡片、分隔线):每条渲染成一个绝对定位空元素,`box` 给位置,`css` 逐项原样写进 '
|
|
2127
|
+
'style;没有 `border-radius` 就按 `0`。只有 `geom: ellipse` 另加 '
|
|
2128
|
+
'`border-radius: 50%`。它们压在背景之上、slot 之下,'
|
|
1787
2129
|
'落在 slot 上的图标正是靠它们托住。',
|
|
1788
|
-
'6.
|
|
2130
|
+
'6. **落实全局设计** —— `design.md` frontmatter 的 `colors`、`typography`、'
|
|
2131
|
+
'`spacing`、`rounded`、`components` 是全局 token;用 CSS variables、类名或内联'
|
|
2132
|
+
'样式承载。局部 slot / decor 的 `css` 优先,不能再解释成另一套视觉系统。'
|
|
2133
|
+
'字体使用 Typography 的完整栈与降级,不在运行时安装字体或依赖。',
|
|
2134
|
+
'7. **保持标题结构** —— 有合适页型可参考时,沿用该页型已有的标题层级与局部 '
|
|
2135
|
+
'`css`;只渲染该页型已有的文字槽,背景中已经可见的固定标题不再创建文本,'
|
|
2136
|
+
'页型没有 `subtitle` 槽就不新增副标题。没有合适参考时,按本包整体视觉组织标题。']
|
|
1789
2137
|
if assets:
|
|
1790
2138
|
L += ['', '资产文件(背景由页型的 `background` 字段指定,'
|
|
1791
2139
|
'图片资产的位置由该页型 `slots` 里带 `asset` 的槽给出):', '',
|
|
1792
|
-
'{{ASSET_TABLE}}'
|
|
2140
|
+
'{{ASSET_TABLE}}', '',
|
|
2141
|
+
'将包内 `assets/` 复制到项目内相对目录,再引用复制后的路径;最终 HTML 不引用'
|
|
2142
|
+
'抽取工作目录或本机绝对路径。附件只提供 `assetRoot` / `assetPaths` 时,把'
|
|
2143
|
+
'`assetRoot` 当作不透明前缀,只拼接清单中声明的相对路径。']
|
|
1793
2144
|
L += ['', '文字与容器的外接矩形落在该页型 `background` 对应的 `text_safe` 内,'
|
|
1794
2145
|
'避开 `avoid` 列出的区域(两者都在 %s 的 `backgrounds` 段)。内容装不下时换页型或拆页。'
|
|
1795
2146
|
% sidecar, '',
|
|
@@ -1804,7 +2155,8 @@ def emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, l
|
|
|
1804
2155
|
',源为商业/内部字体无 web 分发源,按气质降级到 %s' % f['stack'][1]
|
|
1805
2156
|
if len(f['stack']) > 1 else ''))
|
|
1806
2157
|
L += ['', '字号轴:' + '、'.join('%s %dpx' % (k, round(v['sz_px'])) for k, v in roles.items())
|
|
1807
|
-
+ '。slot 自带 `size`
|
|
2158
|
+
+ '。slot 自带 `css` 时以其中的 `font-size` 为准;没有 slot CSS 的新增层级,'
|
|
2159
|
+
'复用轴上最接近的一档。', '',
|
|
1808
2160
|
'字体加载(**HARD REQUIREMENT:下面这行 @import 原样写入全局样式首行,禁止替换为 '
|
|
1809
2161
|
'fonts.googleapis.com 或其他域**):', '', '```', imp, '```', '',
|
|
1810
2162
|
'镜像只保证 wght 400 一档,更粗的字重由浏览器合成,字重不能作为唯一区分手段;'
|
|
@@ -1827,7 +2179,15 @@ def emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, l
|
|
|
1827
2179
|
% (logo['id'], '、'.join('`%s`' % x for x in with_logo) or '(无)'))
|
|
1828
2180
|
L += ['- 坐标、字号、色值、资产位置以 %s 为准;本文件的 Colors / Typography 是可用值的清单。'
|
|
1829
2181
|
% sidecar,
|
|
1830
|
-
'-
|
|
2182
|
+
'- 强调色族以 Colors 和 %s 的 slot CSS 为主;必要时可以使用 Colors 之外的颜色,'
|
|
2183
|
+
'但不能形成与模板主色竞争的第二强调色。' % sidecar,
|
|
2184
|
+
'- 新增颜色应与模板整体的色相、明度和饱和度关系协调。允许新增中性色、低彩度辅助色'
|
|
2185
|
+
'或局部语义色表达正负、风险、警告、状态、图表序列,但保持辅助层级;'
|
|
2186
|
+
'只要新色通过高饱和、高对比、大面积或跨页重复获得主视觉权重,'
|
|
2187
|
+
'或被用于标题、关键数字、图表主序列、卡片底色或渐变,就属于新的强调色,改用模板'
|
|
2188
|
+
'强调色族的深浅、透明度,或改用线型、纹理、标签区分。',
|
|
2189
|
+
'- 交付前逐页检查:色板、字体、版式、背景、资产和本段规则均来自本风格包;'
|
|
2190
|
+
'页面无资源加载失败、内容溢出或画幅裁切。',
|
|
1831
2191
|
'- 本包里的数值就是普查结果,照用即可,无需重新统计颜色、字体或版式。',
|
|
1832
2192
|
'- 风格包以文本形式(zip 摘要等)到手时,直接用摘要里 design.md / layouts.md 的文本。',
|
|
1833
2193
|
'', '## Exceptions', '']
|
|
@@ -1841,9 +2201,9 @@ def emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, l
|
|
|
1841
2201
|
|
|
1842
2202
|
def emit_brief(d, ctx, ldir):
|
|
1843
2203
|
(tokens, rest, fonts, roles, assets, rejected, todos, archetypes, cands, sheet,
|
|
1844
|
-
leftover, lsheet) = ctx
|
|
2204
|
+
leftover, lsheet, sheet_n) = ctx
|
|
1845
2205
|
canvas = d['canvas']['px']
|
|
1846
|
-
L = ['#
|
|
2206
|
+
L = ['# 抽取简报(第 1/3 步产物;改完草案跑 package.py 出包)', '',
|
|
1847
2207
|
'源:`%s` 画布 %dx%d %d 页 / %d 版式 主题 %s form=%s'
|
|
1848
2208
|
% (d['source']['filename'], canvas[0], canvas[1], d['counts']['slides'],
|
|
1849
2209
|
d['counts']['layouts'], d['theme_topology']['themes'],
|
|
@@ -1878,16 +2238,20 @@ def emit_brief(d, ctx, ldir):
|
|
|
1878
2238
|
for t in todos:
|
|
1879
2239
|
L.append('- ' + t)
|
|
1880
2240
|
L += ['', '## 联系表(一次看完所有候选图)', '',
|
|
1881
|
-
'`l-out/contact-sheet.png` ——
|
|
2241
|
+
'`l-out/contact-sheet.png` —— 图格编号对应下表前几行;看完再决定 logo / 封面归属。' if sheet
|
|
1882
2242
|
else '(Pillow 不可用,未生成联系表;逐张看 `media-out/`)', '',
|
|
1883
2243
|
'| # | 文件 | 尺寸 | 出现 | 满屏 | 页 | 草案判定 |', '|---|---|---|---|---|---|---|']
|
|
1884
2244
|
decided = {a['src']['file']: a['id'] for a in assets}
|
|
1885
2245
|
why = {c['file']: r for c, r in rejected}
|
|
1886
|
-
for i, c in enumerate(cands
|
|
2246
|
+
for i, c in enumerate(cands, 1):
|
|
1887
2247
|
L.append('| %d | `%s` | %sx%s | %d | %s | %s | %s |' % (
|
|
1888
2248
|
i, c['file'], c['probe'].get('w') or '?', c['probe'].get('h') or '?', c['n'],
|
|
1889
2249
|
'Y' if c['fullscreen'] else '', ','.join(map(str, c['slides'][:6])) or 'layout',
|
|
1890
2250
|
decided.get(c['file']) or ('✗ ' + why.get(c['file'], '未采纳'))))
|
|
2251
|
+
if len(cands) > sheet_n:
|
|
2252
|
+
L.append('')
|
|
2253
|
+
L.append('拼版图只含前 %d 张(第 %d 行之后的没有图格)。要看后面某张,'
|
|
2254
|
+
'按文件名直接看 `media-out/`。' % (sheet_n, sheet_n))
|
|
1891
2255
|
L += ['', '## 颜色(草案 token 已写进 frontmatter.yaml)', '',
|
|
1892
2256
|
'| token | hex | 出现 |', '|---|---|---|']
|
|
1893
2257
|
for name, r in tokens:
|
|
@@ -1913,7 +2277,7 @@ def emit_brief(d, ctx, ldir):
|
|
|
1913
2277
|
if leftover:
|
|
1914
2278
|
L += ['', '未归入 archetype 的页:%s —— 都是单页孤例,需要就自己补一个 archetype。'
|
|
1915
2279
|
% ', '.join(map(str, leftover))]
|
|
1916
|
-
L += ['', '各 archetype 的 slot
|
|
2280
|
+
L += ['', '各 archetype 的 slot 原文(据此起中文页型名,并在 text_roles 判断文本角色):', '']
|
|
1917
2281
|
for a in archetypes:
|
|
1918
2282
|
L.append('- `%s`(第 %s 页,覆盖 %s)' % (a['name'], a['rep'], a['pages']))
|
|
1919
2283
|
for s in a['slots']:
|
|
@@ -1939,9 +2303,18 @@ def main(argv=None):
|
|
|
1939
2303
|
tokens, rest, rows = draft_colors(d, cusage)
|
|
1940
2304
|
fonts = draft_fonts(d)
|
|
1941
2305
|
archetypes, pages, leftover = draft_layouts(d, outdir)
|
|
2306
|
+
# 封面底图:form=3 的页型键就是角色名(cover/section/...),直接按名字取。
|
|
2307
|
+
# form=2 按样张聚类,键是 layout-1..N,永远匹配不上 'cover'——实测 vo-lite 因此
|
|
2308
|
+
# 一张 role: cover 都没有,封面主视觉被标成 bg-content-1,消费端拿不到封面资产,
|
|
2309
|
+
# design.md 的「封面底图必用 cover 资产」这条硬规则无从满足。回退到覆盖第 1 页的
|
|
2310
|
+
# 那个页型:deck 的第 1 页就是封面,这是版式无关的事实。
|
|
1942
2311
|
cover_media = next((a['bg_raw'] for a in archetypes if a['name'] == 'cover'), None)
|
|
1943
|
-
|
|
1944
|
-
|
|
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)
|
|
2315
|
+
exported_media = {m['media'] for m in d.get('media', []) if m.get('exported')}
|
|
2316
|
+
bg_needed = {a['bg_raw'] for a in archetypes if a['bg_raw'] in exported_media}
|
|
2317
|
+
bg_under = {p['no']: p.get('rendered_bg') or p['bg_media'] for p in pages}
|
|
1945
2318
|
assets, rejected, todos, alias, pool = draft_assets(d, outdir, bg_needed, cover_media, bg_under)
|
|
1946
2319
|
media_to_asset = {a['src']['media']: a['id'] for a in assets}
|
|
1947
2320
|
for m, w in (alias or {}).items():
|
|
@@ -1995,9 +2368,23 @@ def main(argv=None):
|
|
|
1995
2368
|
a['slots'] = keep
|
|
1996
2369
|
roles = draft_scale(d, archetypes)
|
|
1997
2370
|
slot_added = cover_slot_colors(tokens, archetypes, rows, cusage)
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2371
|
+
# 进包的资产必须全部上联系表。BRIEF 让 L 层「看联系表确认 logo / 封面归属」,
|
|
2372
|
+
# 表上没有的东西它只会从表里另挑一张顶上去。封面主视觉按定义只出现在封面那一页
|
|
2373
|
+
# (n=1),按出现次数排序时排在最末——实测被 cands[:12] 截掉,模型于是把 bg-cover
|
|
2374
|
+
# 换成了已经在用的内容页背景,封面与内容页字节相同,封面主视觉整个丢失。
|
|
2375
|
+
decided_c = sorted([a['src'] for a in assets], key=lambda c: (-c['n'], c['file']))
|
|
2376
|
+
other_c = sorted([c for c, _ in rejected], key=lambda c: (-c['n'], c['file']))
|
|
2377
|
+
cands, seen_file = [], set()
|
|
2378
|
+
for c in decided_c + other_c: # 同一张图可能有多条候选记录(不同位置各一条)
|
|
2379
|
+
if c['file'] not in seen_file:
|
|
2380
|
+
seen_file.add(c['file'])
|
|
2381
|
+
cands.append(c)
|
|
2382
|
+
# 表列全部候选,拼版图只拼前几张:两者成本差着数量级。表是文字,60 行也几乎不占
|
|
2383
|
+
# 上下文,却是模型唯一能知道「存在这张图」的地方——名额砍在这里,被误判成未采纳的
|
|
2384
|
+
# 图连翻案的机会都没有。拼版图是要「看」的,60 格就是 4 列×15 行、降采样后每格
|
|
2385
|
+
# 糊成一团,那个上限才有意义。
|
|
2386
|
+
sheet_items = cands[:max(SHEET_CAP, len(decided_c))]
|
|
2387
|
+
sheet = contact_sheet(outdir, sheet_items, os.path.join(ldir, 'contact-sheet.png'))
|
|
2001
2388
|
lsheet = layout_sheet(outdir, archetypes, os.path.join(ldir, 'layout-sheet.png'))
|
|
2002
2389
|
|
|
2003
2390
|
anchors = draft_anchors(d, tokens, fonts, roles, assets, archetypes)
|
|
@@ -2040,7 +2427,7 @@ def main(argv=None):
|
|
|
2040
2427
|
gaps.append('源字体 %s 不在 font-fallback 表里,字体栈只有原名,消费端很可能装不上;'
|
|
2041
2428
|
'按气质挑一个有 web 分发源的近似体补进栈,不要照抄原名。' % f['names'][0])
|
|
2042
2429
|
nosize = [(a['name'], s['box']) for a in archetypes for s in a['slots']
|
|
2043
|
-
if not s.get('asset') and not s.get('
|
|
2430
|
+
if not s.get('asset') and not s.get('_font_size')]
|
|
2044
2431
|
if nosize:
|
|
2045
2432
|
gaps.append('这些文字槽在源文件任何层级都没有字号声明(都不是占位符,是普通文本框,'
|
|
2046
2433
|
'继承源是 presentation.xml 的 defaultTextStyle,本抽取按约定不解继承链):'
|
|
@@ -2068,12 +2455,15 @@ def main(argv=None):
|
|
|
2068
2455
|
emit_layouts(archetypes, ldir, busy_hints, facts, recipes)
|
|
2069
2456
|
emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, ldir)
|
|
2070
2457
|
emit_brief(d, (tokens, rest, fonts, roles, assets, rejected, todos, archetypes, cands, sheet,
|
|
2071
|
-
leftover, lsheet), ldir)
|
|
2458
|
+
leftover, lsheet, len(sheet_items)), ldir)
|
|
2072
2459
|
|
|
2073
|
-
|
|
2074
|
-
|
|
2460
|
+
# 这几行落在模型判断「skill 是不是做完了」的那一刻。只报数就会被读成「包已生成」,
|
|
2461
|
+
# 于是判断和打包整段被跳过,deck 拿不到任何版式坐标。所以这里报进度与下一条命令。
|
|
2462
|
+
print('第 1/3 步完成,判断单草案 -> %s' % ldir)
|
|
2463
|
+
print(' 待你确认:资产 %d(%s) 版式 %d 色 %d 字体 %d'
|
|
2075
2464
|
% (len(assets), ', '.join(x['id'] for x in assets), len(archetypes), len(tokens), len(fonts)))
|
|
2076
|
-
print('
|
|
2465
|
+
print(' 第 2 步 读 l-out/BRIEF.md 与 contact-sheet.png,改掉草案里的 TODO')
|
|
2466
|
+
print(' 第 3 步 package.py 产出 design.md + layouts.md —— deck 的版式坐标只从这两份读')
|
|
2077
2467
|
sys.stdout.flush()
|
|
2078
2468
|
return 0
|
|
2079
2469
|
|