@lark-apaas/coding-steering 0.1.18-dev.4e64c13 → 0.1.18-dev.61b3ece

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (24) hide show
  1. package/package.json +1 -1
  2. package/steering/design-html/skills/charts/SKILL.md +4 -0
  3. package/steering/design-html/skills/pptx-style-extract/SKILL.md +11 -8
  4. package/steering/design-html/skills/pptx-style-extract/scripts/check_v2.py +33 -3
  5. package/steering/design-html/skills/pptx-style-extract/scripts/draft.py +526 -110
  6. package/steering/design-html/skills/pptx-style-extract/scripts/extract.py +226 -6
  7. package/steering/design-html/skills/pptx-style-extract/scripts/ooxml.py +18 -1
  8. package/steering/design-html/skills/pptx-style-extract/scripts/package.py +167 -28
  9. package/steering/design-html/skills/pptx-style-extract/scripts/parts.py +3 -0
  10. package/steering/design-html/skills/pptx-style-extract/scripts/query.py +3 -8
  11. package/steering/design-html/skills/pptx-style-extract/scripts/test_background_composite.py +57 -0
  12. package/steering/design-html/skills/pptx-style-extract/scripts/test_color_contract.py +60 -0
  13. package/steering/design-html/skills/pptx-style-extract/scripts/test_design_consumer_contract.py +63 -0
  14. package/steering/design-html/skills/pptx-style-extract/scripts/test_flow_layout_contract.py +468 -0
  15. package/steering/design-html/skills/pptx-style-extract/scripts/test_layout_css.py +127 -0
  16. package/steering/design-html/skills/pptx-style-extract/scripts/test_rounded_contract.py +112 -0
  17. package/steering/design-html/skills/pptx-style-extract/scripts/test_text_role_contract.py +208 -0
  18. package/steering/design-html/skills/pptx-style-extract/v2-format-spec.md +14 -7
  19. package/steering/design-html/skills/slide-deck/SKILL.md +15 -20
  20. package/steering/design-html/skills/slide-deck/scripts/check_local_references.py +179 -0
  21. package/steering/nestjs-react-fullstack/skills/plugin-guide/SKILL.md +5 -3
  22. package/steering/nestjs-react-fullstack/skills_local/plugin-guide/SKILL.md +4 -0
  23. package/steering/vite-react/skills/plugin-guide/SKILL.md +3 -1
  24. package/steering/vite-react/skills/react-three-fiber/SKILL.md +4 -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('color') or '').upper()
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('weight') for a in archetypes for s in a['slots'] if s.get('weight')}
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,84 @@ 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
- out['size'] = round(ls['sz_px'])
985
+ # normAutofit 的 fontScale 是模板让大字装进小框的手段——不乘它,消费端拿到的是
986
+ # 未缩放字号,字比框高,渐变裁切会把溢出的底部切成透明。缺省 1.0(无 autofit / 无缩放)。
987
+ scale = body.get('font_scale')
988
+ raw = ls['sz_px'] * scale if scale else ls['sz_px']
989
+ size = round(raw)
990
+ css.append('font-size: %dpx' % size)
991
+ out['_font_size'] = size
992
+ weight = ls.get('weight') or (700 if ls.get('bold') else None)
993
+ if weight:
994
+ css.append('font-weight: %s' % weight)
995
+ out['_font_weight'] = weight
996
+ if ls.get('italic'):
997
+ css.append('font-style: italic')
998
+ decorations = []
999
+ if ls.get('underline'):
1000
+ decorations.append('underline')
1001
+ if ls.get('strike'):
1002
+ decorations.append('line-through')
1003
+ if decorations:
1004
+ css.append('text-decoration: %s' % ' '.join(decorations))
1005
+ if ls.get('spc_px') is not None:
1006
+ css.append('letter-spacing: %spx' % css_number(ls['spc_px']))
925
1007
  col = (ls.get('color') or {}).get('resolved')
926
1008
  if col:
927
- out['color'] = col
928
- if ls.get('weight'):
929
- out['weight'] = ls['weight']
930
- elif ls.get('bold'):
931
- out['weight'] = 700
932
- if ls.get('algn') and ls['algn'] not in ('l', 'just'):
933
- out['align'] = {'ctr': 'center', 'r': 'right'}.get(ls['algn'], ls['algn'])
934
- anchor = ((s.get('text') or {}).get('bodyPr') or {}).get('anchor')
1009
+ css.append('color: %s' % col)
1010
+ out['_color'] = col
1011
+ else:
1012
+ # 占位符的字色也可以是 gradFill(章节页的大号序号常这么做)。解析层已经把
1013
+ # stops 和角度记全了,这里只取单色就会整条丢掉,消费端只能自己编一个平色。
1014
+ # decor 同一约定:css 是可直接写进 style 的声明串。
1015
+ f = ls.get('fill') or {}
1016
+ if f.get('type') == 'gradient':
1017
+ g = _load_query()._css_gradient(f)
1018
+ if g:
1019
+ css += ['background-image: %s' % g, '-webkit-background-clip: text',
1020
+ 'background-clip: text', 'color: transparent']
1021
+ align = ls.get('algn')
1022
+ if align:
1023
+ css.append('text-align: %s' % {
1024
+ 'l': 'left', 'ctr': 'center', 'r': 'right', 'just': 'justify',
1025
+ }.get(align, align))
1026
+ line_spacing = ls.get('lnSpc') or {}
1027
+ # normAutofit 的 lnSpcReduction 与 fontScale 同时把行距压缩,一起缩才装得进原框。
1028
+ reduction = body.get('ln_spc_reduction') or 0
1029
+ if line_spacing.get('mult'):
1030
+ mult = line_spacing['mult'] * 1.2 * (1 - reduction)
1031
+ css.append('line-height: %s' % css_number(mult))
1032
+ elif line_spacing.get('px'):
1033
+ css.append('line-height: %spx' % css_number(line_spacing['px'] * (1 - reduction)))
1034
+ anchor = body.get('anchor')
935
1035
  if anchor in ('ctr', 'b'):
936
- out['valign'] = {'ctr': 'middle', 'b': 'bottom'}[anchor]
1036
+ css += ['display: flex', 'flex-direction: column',
1037
+ 'justify-content: %s' % {'ctr': 'center', 'b': 'flex-end'}[anchor]]
1038
+ if body.get('rot'):
1039
+ try:
1040
+ degrees = float(body['rot']) / 60000.0
1041
+ css.append('rotate: %sdeg' % css_number(degrees))
1042
+ except (TypeError, ValueError):
1043
+ pass
1044
+ if css:
1045
+ out['css'] = '; '.join(css)
937
1046
  return out
938
1047
 
939
1048
 
940
- def instance_override(shapes, slide_part, slots, bgm, cW, cH):
1049
+ def instance_override(shapes, slide_part, slots, bgm, cW, cH, composites=None):
941
1050
  """实例页覆盖版式:版式是骨架,实例页才是设计师最终摆定的样子。
942
1051
 
943
1052
  版式底图常是多个版式共用的通用底纹,实例页可能另铺主视觉大图;标题占位符的框高
@@ -947,11 +1056,7 @@ def instance_override(shapes, slide_part, slots, bgm, cW, cH):
947
1056
  ins = [s for s in shapes if s.get('part') == slide_part]
948
1057
  if not ins:
949
1058
  return slots, bgm
950
- for s in ins: # 实例页自己铺的满屏图优先
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
1059
+ bgm = (composites or {}).get(slide_part) or top_bleed_media(ins) or bgm
955
1060
  texts = []
956
1061
  for s in ins:
957
1062
  b = s.get('box') or {}
@@ -979,13 +1084,14 @@ def layouts_from_template(d, shapes, cW, cH):
979
1084
  """
980
1085
  by_part = defaultdict(list)
981
1086
  for s in shapes:
982
- if s.get('layer') == 'layout' and s.get('ph'):
1087
+ if (s.get('layer') == 'layout' and s.get('kind') == 'sp'
1088
+ and (s.get('box') or {}).get('w') and (s.get('ph') or shape_text(s))):
983
1089
  by_part[s['part']].append(s)
984
1090
  bg_of_layout = {}
1091
+ composites = d.get('background_composites') or {}
985
1092
  for s in shapes:
986
- if (s.get('layer') == 'layout' and s.get('kind') == 'pic'
987
- and s.get('w_pct', 0) >= 95 and s.get('h_pct', 0) >= 95):
988
- bg_of_layout.setdefault(s['part'], s.get('media'))
1093
+ if s.get('layer') == 'layout' and is_bleed(s) and s.get('media'):
1094
+ bg_of_layout[s['part']] = s['media'] # 靠后者在上层,最后一张才是看得见的
989
1095
  topo = d.get('theme_topology') or {}
990
1096
  theme_of_master = {m['master']: m.get('theme_label')
991
1097
  for m in (topo.get('per_master') or [])}
@@ -1007,11 +1113,11 @@ def layouts_from_template(d, shapes, cW, cH):
1007
1113
  phs.sort(key=lambda s: ((s['box'].get('y') or 0), (s['box'].get('x') or 0)))
1008
1114
  slots, seen_kind = [], set()
1009
1115
  for s in phs:
1010
- t = PH_TO_TYPE.get((s['ph'] or {}).get('type'), 'body')
1011
- if t in ('slide-number', 'footer'):
1012
- continue # 页码/页脚属 chrome,不是内容槽
1116
+ t = PH_TO_TYPE.get((s.get('ph') or {}).get('type'), 'body')
1117
+ if t in ('slide-number', 'footer') and not shape_text(s):
1118
+ continue # chrome 占位符不是实际元素
1013
1119
  b = s['box']
1014
- role = t if t in ('title', 'subtitle') else 'body'
1120
+ role = t if t in ('title', 'subtitle', 'footer', 'slide-number') else 'body'
1015
1121
  if t == 'title' and 'title' in seen_kind:
1016
1122
  role, t = 'subtitle', 'subtitle'
1017
1123
  seen_kind.add(t)
@@ -1020,10 +1126,18 @@ def layouts_from_template(d, shapes, cW, cH):
1020
1126
  round(b.get('w', 0)), round(b.get('h', 0))],
1021
1127
  'txt': shape_text(s) or (s.get('name') or '')[:24]}
1022
1128
  row.update(slot_style(s))
1129
+ if t == 'body':
1130
+ ph = s.get('ph') or {}
1131
+ row.update({
1132
+ '_needs_role': True,
1133
+ '_source_layer': 'layout',
1134
+ '_placeholder': '%s/%s' % (
1135
+ ph.get('type') or '-', ph.get('idx') or '-'),
1136
+ })
1023
1137
  slots.append(row)
1024
1138
  # 非满屏的图片元素(logo / 联名标 / 装饰)——它们逐版式换位置换尺寸,
1025
1139
  # 必须按版式落进 slots,压成一条全局「固定位」规则就会撞标题。
1026
- bgm = bg_of_layout.get(l['part'])
1140
+ bgm = composites.get(l['part']) or bg_of_layout.get(l['part'])
1027
1141
  for s in shapes:
1028
1142
  if s['part'] != l['part'] or s.get('kind') != 'pic' or not s.get('media'):
1029
1143
  continue
@@ -1040,7 +1154,8 @@ def layouts_from_template(d, shapes, cW, cH):
1040
1154
  continue
1041
1155
  inst = slide_of_layout.get(l['part'])
1042
1156
  if inst:
1043
- slots, bgm = instance_override(shapes, inst, slots, bgm, cW, cH)
1157
+ slots, bgm = instance_override(
1158
+ shapes, inst, slots, bgm, cW, cH, composites)
1044
1159
  taken = {tuple(s['box']) for s in slots}
1045
1160
  decor = collect_decor(shapes, inst or l['part'], taken, (cW, cH))
1046
1161
  named_role = role_of_name(l.get('name'))
@@ -1138,17 +1253,76 @@ def collect_decor(shapes, part, taken_boxes, canvas, limit=10):
1138
1253
  return out[:limit] # 同款不同位置都要留,位置本身是版式信息
1139
1254
 
1140
1255
 
1256
+ def placeholder_key(shape):
1257
+ ph = shape.get('ph') or {}
1258
+ if not ph:
1259
+ return None
1260
+ return (ph.get('type') or 'body', str(ph.get('idx') or ''))
1261
+
1262
+
1263
+ def merge_dict(base, override):
1264
+ """把实例页的非空声明叠到版式声明上;空实例占位符继续继承版式事实。"""
1265
+ out = copy.deepcopy(base or {})
1266
+ for key, value in (override or {}).items():
1267
+ if value is None or value == []:
1268
+ continue
1269
+ if isinstance(value, dict) and isinstance(out.get(key), dict):
1270
+ out[key] = merge_dict(out[key], value)
1271
+ else:
1272
+ out[key] = copy.deepcopy(value)
1273
+ return out
1274
+
1275
+
1276
+ def inherited_text_shapes(layout_shapes, slide_shapes):
1277
+ """返回实例页可用的文字形状,并补齐其引用版式中的占位符几何与样式。"""
1278
+ layout_text = []
1279
+ for shape in layout_shapes:
1280
+ if shape.get('kind') != 'sp' or not (shape.get('box') or {}).get('w'):
1281
+ continue
1282
+ ph = shape.get('ph') or {}
1283
+ ph_type = ph.get('type')
1284
+ if shape_text(shape) or (ph and ph_type not in ('ftr', 'dt', 'sldNum')):
1285
+ layout_text.append(shape)
1286
+ by_placeholder = {placeholder_key(s): s for s in layout_text if placeholder_key(s)}
1287
+ used = set()
1288
+ out = []
1289
+ for shape in slide_shapes:
1290
+ if shape.get('kind') != 'sp':
1291
+ continue
1292
+ key = placeholder_key(shape)
1293
+ base = by_placeholder.get(key)
1294
+ if base:
1295
+ merged = merge_dict(base, shape)
1296
+ merged['text'] = merge_dict(base.get('text'), shape.get('text'))
1297
+ if not shape_text(shape):
1298
+ merged['text']['paragraphs'] = copy.deepcopy(
1299
+ (base.get('text') or {}).get('paragraphs') or [])
1300
+ used.add(key)
1301
+ out.append((merged, 'slide+layout'))
1302
+ elif (shape.get('box') or {}).get('w') and shape_text(shape):
1303
+ out.append((shape, 'slide'))
1304
+ for shape in layout_text:
1305
+ key = placeholder_key(shape)
1306
+ if key not in used:
1307
+ out.append((shape, 'layout'))
1308
+ return out
1309
+
1310
+
1141
1311
  def draft_layouts(d, outdir):
1142
- shapes = json.load(open(os.path.join(outdir, 'ref', 'shapes.json'), encoding='utf-8'))['shapes']
1312
+ with open(os.path.join(outdir, 'ref', 'shapes.json'), encoding='utf-8') as stream:
1313
+ shapes = json.load(stream)['shapes']
1143
1314
  cW, cH = d['canvas']['px']
1144
1315
  if (d.get('form_hint') or {}).get('form') == 3:
1145
1316
  arch = layouts_from_template(d, shapes, cW, cH)
1146
1317
  if len(arch) >= 3:
1147
1318
  return arch, [], []
1148
1319
  by_slide = defaultdict(list)
1320
+ by_layout = defaultdict(list)
1149
1321
  for s in shapes:
1150
1322
  if s.get('layer') == 'slide':
1151
1323
  by_slide[s['part']].append(s)
1324
+ elif s.get('layer') == 'layout':
1325
+ by_layout[s['part']].append(s)
1152
1326
 
1153
1327
  bg_of_slide, layout_of_slide = {}, {}
1154
1328
  for s in d.get('slides', []):
@@ -1156,40 +1330,51 @@ def draft_layouts(d, outdir):
1156
1330
  bg_of_slide[s['part']] = json.dumps(bg, sort_keys=True) if isinstance(bg, dict) else bg
1157
1331
  layout_of_slide[s['part']] = s.get('layout')
1158
1332
  # 版式层的满屏底图(form=2 常态:底图挂在 layout 上)
1333
+ composites = d.get('background_composites') or {}
1159
1334
  bg_of_layout = {}
1160
1335
  for s in shapes:
1161
- if (s.get('layer') == 'layout' and s.get('kind') == 'pic'
1162
- and s.get('w_pct', 0) >= 95 and s.get('h_pct', 0) >= 95):
1163
- bg_of_layout.setdefault(s['part'], s.get('media'))
1336
+ if s.get('layer') == 'layout' and is_bleed(s) and s.get('media'):
1337
+ bg_of_layout[s['part']] = s['media']
1164
1338
 
1165
1339
  pages = []
1166
1340
  for part, sh in sorted(by_slide.items(), key=lambda kv: slide_no(kv[0])):
1167
- bg_media = None
1168
- for s in sh:
1169
- if s.get('kind') == 'pic' and s.get('w_pct', 0) >= 95 and s.get('h_pct', 0) >= 95:
1170
- bg_media = s.get('media')
1171
- break
1341
+ layout_part = layout_of_slide.get(part)
1342
+ layout_shapes = by_layout.get(layout_part) or []
1343
+ bg_media = top_bleed_media(sh)
1172
1344
  if bg_media is None:
1173
- bg_media = bg_of_layout.get(layout_of_slide.get(part))
1345
+ bg_media = bg_of_layout.get(layout_part)
1346
+ rendered_bg = (composites.get(part)
1347
+ or composites.get(layout_part)
1348
+ or bg_media)
1174
1349
  texts = []
1175
- for s in sh:
1176
- if s.get('kind') != 'sp':
1177
- continue
1178
- txt = shape_text(s)
1179
- if not txt:
1180
- continue
1350
+ for s, source_layer in inherited_text_shapes(layout_shapes, sh):
1351
+ txt = shape_text(s) or (s.get('name') or '')[:24]
1181
1352
  b = s.get('box') or {}
1182
1353
  if b.get('w', 0) < DECOR_MIN or b.get('h', 0) < 16:
1183
1354
  continue
1184
- texts.append({'sz': shape_sz(s), 'box': b, 'txt': txt, 'style': slot_style(s)})
1355
+ ph = s.get('ph') or {}
1356
+ ph_type = ph.get('type')
1357
+ direct_type = PH_TO_TYPE.get(ph_type, 'body')
1358
+ texts.append({
1359
+ 'sz': shape_sz(s),
1360
+ 'box': b,
1361
+ 'txt': txt,
1362
+ 'style': slot_style(s),
1363
+ 'direct_type': direct_type,
1364
+ 'needs_role': direct_type == 'body',
1365
+ 'source_layer': source_layer,
1366
+ 'placeholder': '%s/%s' % (ph_type or '-', ph.get('idx') or '-'),
1367
+ })
1185
1368
  texts.sort(key=lambda t: (-t['sz'], t['box'].get('y', 0)))
1186
- pics = [s for s in sh if s.get('kind') == 'pic' and s.get('w_pct', 0) < 95]
1369
+ visible_shapes = layout_shapes + sh
1370
+ pics = [s for s in visible_shapes if s.get('kind') == 'pic' and s.get('w_pct', 0) < 95]
1187
1371
  # 小图元素(logo / 角标 / 装饰)逐页记位置,供 archetype 落 slots
1188
1372
  marks = [{'media': s['media'], 'box': s['box']} for s in pics
1189
1373
  if s.get('media') and (s.get('box') or {}).get('w') and s.get('w_pct', 0) < 30]
1190
1374
  pages.append({'part': part, 'no': slide_no(part), 'bg_media': bg_media,
1375
+ 'rendered_bg': rendered_bg,
1191
1376
  'bg_color': bg_of_slide.get(part), 'texts': texts, 'pic_n': len(pics),
1192
- 'marks': marks, 'shape_n': len(sh)})
1377
+ 'marks': marks, 'shape_n': len(visible_shapes), 'layout': layout_part})
1193
1378
 
1194
1379
  # 页型的**角色**(封面 / 章节页 / 内容页……)不在这里判:那是看图才能下的结论,
1195
1380
  # 交给读得到重建图的模型。脚本只做客观归并——同一张底图 + 文字块数量相近的页
@@ -1227,6 +1412,9 @@ def draft_layouts(d, outdir):
1227
1412
  rep = max(ps, key=lambda p: len(p['texts']))
1228
1413
  if bg_raw == '__first__':
1229
1414
  bg_raw = rep['bg_media'] or rep['bg_color'] or 'none'
1415
+ rendered_bg = rep.get('rendered_bg')
1416
+ if rendered_bg:
1417
+ bg_raw = rendered_bg
1230
1418
  name = 'layout-%d' % gi
1231
1419
  # 标题按「位置 + 跨度」认,不按字号——big-number 类的巨号数值常比标题还大
1232
1420
  # 标题 = 该页最靠上的那批文本里最宽的一块。不按「画布前 28%」这类固定比例切:
@@ -1240,12 +1428,13 @@ def draft_layouts(d, outdir):
1240
1428
  rest.sort(key=lambda t: (t['box'].get('y', 0), t['box'].get('x', 0)))
1241
1429
  ordered = ([title] if title else []) + rest
1242
1430
  slots = []
1243
- note_truncation('文字槽', 6, len(ordered),
1244
- '需要更多同类槽时,按已有同类槽的间距等距延续,不要另起一套网格'
1245
- '——包里的坐标是模板量出来的,自创网格等于放弃这套版式', name)
1246
- for i, t in enumerate(ordered[:6]):
1431
+ for i, t in enumerate(ordered):
1247
1432
  b = t['box']
1248
- if t is title:
1433
+ if t.get('needs_role'):
1434
+ role = typ = 'body'
1435
+ elif t.get('direct_type') in ('title', 'subtitle', 'footer', 'slide-number'):
1436
+ role = typ = t['direct_type']
1437
+ elif t is title:
1249
1438
  role = typ = 'title'
1250
1439
  elif (title and i == 1
1251
1440
  # 副标题 = 紧跟在标题下方、与标题左对齐的那一块。三个量都相对标题
@@ -1261,6 +1450,12 @@ def draft_layouts(d, outdir):
1261
1450
  round(b.get('w', 0)), round(b.get('h', 0))],
1262
1451
  'type': typ, 'sz': t['sz'], 'txt': t['txt']}
1263
1452
  row.update(t.get('style') or {})
1453
+ if t.get('needs_role'):
1454
+ row.update({
1455
+ '_needs_role': True,
1456
+ '_source_layer': t.get('source_layer'),
1457
+ '_placeholder': t.get('placeholder'),
1458
+ })
1264
1459
  slots.append(row)
1265
1460
  # 代表页上的小图元素按位置去重后落 slots(同一 logo 在不同页型位置不同)
1266
1461
  seen_mark = set()
@@ -1274,7 +1469,15 @@ def draft_layouts(d, outdir):
1274
1469
  'media': mk['media'],
1275
1470
  'box': [round(b.get('x', 0)), round(b.get('y', 0)),
1276
1471
  round(b.get('w', 0)), round(b.get('h', 0))]})
1277
- decor = collect_decor(shapes, rep['part'], {tuple(s['box']) for s in slots}, (cW, cH))
1472
+ taken = {tuple(s['box']) for s in slots}
1473
+ decor = []
1474
+ seen_decor = set()
1475
+ for source_part in (rep.get('layout'), rep['part']):
1476
+ for item in collect_decor(shapes, source_part, taken, (cW, cH)):
1477
+ key = (tuple(item['box']), item['geom'], item['css'])
1478
+ if key not in seen_decor:
1479
+ seen_decor.add(key)
1480
+ decor.append(item)
1278
1481
  archetypes.append({'name': name, 'bg': None, 'bg_raw': bg_raw, 'slots': slots,
1279
1482
  'decor': decor,
1280
1483
  'pages': sorted(p['no'] for p in ps), 'rep': rep['no'],
@@ -1337,7 +1540,7 @@ def contact_sheet(outdir, cands, path):
1337
1540
  except Exception:
1338
1541
  return None
1339
1542
  cell, pad, cols = 220, 20, 4
1340
- items = cands[:12]
1543
+ items = cands # 上限由调用方定,编号与 BRIEF 表格一一对应
1341
1544
  if not items:
1342
1545
  return None
1343
1546
  rows = (len(items) + cols - 1) // cols
@@ -1424,10 +1627,11 @@ def emit_frontmatter(d, tokens, fonts, roles, anchors, gaps, ldir):
1424
1627
  L.append('spacing:')
1425
1628
  L.append(' page-padding: {top: %s, right: %s, bottom: %s, left: %s}'
1426
1629
  % (edge['top'], edge['right'], edge['bottom'], edge['left']))
1427
- # 只排除「圆角量为零」(那是直角不是圆角),不再设「出现几次才算数」的门槛
1428
- radii = [r for r in (d.get('radii_census') or []) if r['px'] >= 1]
1429
- if radii:
1430
- top = max(radii, key=lambda r: r['n'])
1630
+ # rounded.card 是全局 token,只能表达全档共同的一档圆角。多个非零档位或零/非零
1631
+ # 混用时,圆角属于 layouts.md 里的局部形状事实,压成一个值会把直角容器也圆角化。
1632
+ radii = d.get('radii_census') or []
1633
+ if len(radii) == 1 and radii[0]['px'] >= 1:
1634
+ top = radii[0]
1431
1635
  L.append('rounded:')
1432
1636
  L.append(' card: %dpx' % round(top['px']))
1433
1637
  if edges_full:
@@ -1463,9 +1667,13 @@ def draft_flow(a, facts, canvas):
1463
1667
  cW, cH = canvas
1464
1668
  # 装饰件也算进来:很多模板的版式层只有几个占位符,真正撑起版面的是卡片容器
1465
1669
  # (在 decor 里)。只看 slots 会把一页的主体结构整个漏掉。
1466
- items = [s for s in a['slots'] if s.get('box')]
1467
- items += [{'role': 'container', 'type': 'decor', 'box': dcr['box'], 'css': dcr.get('css')}
1468
- for dcr in (a.get('decor') or [])]
1670
+ slots = [s for s in a['slots'] if s.get('box')]
1671
+ fixed_roles = {'logo', 'slide-number', 'page-number', 'header', 'footer'}
1672
+ fixed = [s for s in slots if s.get('role') in fixed_roles]
1673
+ content_slots = [s for s in slots if s.get('role') not in fixed_roles]
1674
+ containers = [{'role': 'container', 'type': 'decor', 'box': dcr['box'],
1675
+ 'css': dcr.get('css')} for dcr in (a.get('decor') or [])]
1676
+ items = group_flow_cards(content_slots, containers)
1469
1677
  if len(items) < 2:
1470
1678
  return None
1471
1679
  items.sort(key=lambda s: (s['box'][1], s['box'][0]))
@@ -1476,7 +1684,7 @@ def draft_flow(a, facts, canvas):
1476
1684
  return None
1477
1685
  # 区带边界 = 间距分布里的最大空档。同一区带内部的间距(网格行距之类)总是明显
1478
1686
  # 小于区带之间的间距,用本页自己的分布切,不设固定阈值。
1479
- cut = _gap_cut(pos, min(pos), max(pos)) if len(pos) > 1 else max(pos) + 1
1687
+ cut = _gap_cut(pos, min(pos), max(pos)) if len(pos) > 1 else pos[0]
1480
1688
  regions, cur = [], [items[0]]
1481
1689
  for i, g in enumerate(gaps):
1482
1690
  if g >= cut:
@@ -1485,6 +1693,11 @@ def draft_flow(a, facts, canvas):
1485
1693
  cur.append(items[i + 1])
1486
1694
  regions.append(cur)
1487
1695
 
1696
+ # 整页左右边距 = 所有内容的横向外包络,作为各区带的缺省。
1697
+ lefts = [s['box'][0] for s in items]
1698
+ rights = [s['box'][0] + s['box'][2] for s in items]
1699
+ page_margin = [min(lefts), cW - max(rights)]
1700
+
1488
1701
  out = []
1489
1702
  for reg in regions:
1490
1703
  if not reg:
@@ -1507,8 +1720,17 @@ def draft_flow(a, facts, canvas):
1507
1720
  if len(rows) > 1:
1508
1721
  row_gap = round(rows[1][0]['box'][1]
1509
1722
  - (rows[0][0]['box'][1] + rows[0][0]['box'][3]))
1510
- out.append({'kind': 'grid', 'cols': cols, 'gap': [max(col_gap, 0), max(row_gap, 0)],
1511
- 'items': rows[0]})
1723
+ region = {'kind': 'grid', 'cols': cols, 'gap': [max(col_gap, 0), max(row_gap, 0)],
1724
+ 'items': rows[0]}
1725
+ # 卡片组的横向范围常和整页不同(标题贴左、卡片居中)。整页边距是所有元素的
1726
+ # 外包络,直接套给居中卡片组会把它拉偏成左对齐。区带范围和整页明显不一致时,
1727
+ # 落这个区带自己的左右边距,消费端把网格放进它再填 1fr。按落盘的整数比较,
1728
+ # 亚像素噪声不触发多余的区带边距。
1729
+ reg_margin = [min(s['box'][0] for s in rows[0]),
1730
+ cW - max(s['box'][0] + s['box'][2] for s in rows[0])]
1731
+ if [int(reg_margin[0]), int(reg_margin[1])] != [int(page_margin[0]), int(page_margin[1])]:
1732
+ region['margin'] = reg_margin
1733
+ out.append(region)
1512
1734
  elif len(rows) == len(reg):
1513
1735
  # 每行一个元素 = 真的竖着排
1514
1736
  inner = 0
@@ -1519,14 +1741,91 @@ def draft_flow(a, facts, canvas):
1519
1741
  # 每行元素数不一致(比如左列两张、右列一张跨两行)。硬说成 stack 会让消费端
1520
1742
  # 以为它们是竖排的,比不给还糟。如实说这块推不出规整结构,按坐标摆。
1521
1743
  out.append({'kind': 'free', 'items': reg})
1744
+ if fixed:
1745
+ out.append({'kind': 'free', 'items': fixed})
1522
1746
  if len(out) < 2:
1523
1747
  return None
1524
- lefts = [s['box'][0] for s in items]
1525
- rights = [s['box'][0] + s['box'][2] for s in items]
1526
- return {'top': items[0]['box'][1], 'margin': [min(lefts), cW - max(rights)],
1748
+ return {'top': items[0]['box'][1], 'margin': page_margin,
1527
1749
  'gap': round(cut), 'regions': out}
1528
1750
 
1529
1751
 
1752
+ def box_contains(outer, inner):
1753
+ return (outer[0] <= inner[0] and outer[1] <= inner[1]
1754
+ and outer[0] + outer[2] >= inner[0] + inner[2]
1755
+ and outer[1] + outer[3] >= inner[1] + inner[3])
1756
+
1757
+
1758
+ def boxes_overlap(a, b):
1759
+ return (min(a[0] + a[2], b[0] + b[2]) > max(a[0], b[0])
1760
+ and min(a[1] + a[3], b[1] + b[3]) > max(a[1], b[1]))
1761
+
1762
+
1763
+ def overlap_ratio(outer, inner):
1764
+ width = min(outer[0] + outer[2], inner[0] + inner[2]) - max(outer[0], inner[0])
1765
+ height = min(outer[1] + outer[3], inner[1] + inner[3]) - max(outer[1], inner[1])
1766
+ if width <= 0 or height <= 0 or inner[2] <= 0 or inner[3] <= 0:
1767
+ return 0
1768
+ return width * height / (inner[2] * inner[3])
1769
+
1770
+
1771
+ def group_flow_cards(slots, containers):
1772
+ """把并列卡片容器及其文字组成一层 group,避免拍平成多列元素。"""
1773
+ candidates = []
1774
+ for container in containers:
1775
+ children = [slot for slot in slots if box_contains(container['box'], slot['box'])]
1776
+ if len(children) >= 2:
1777
+ candidates.append((container, children))
1778
+ selected = []
1779
+ for container, children in sorted(
1780
+ candidates, key=lambda pair: pair[0]['box'][2] * pair[0]['box'][3]):
1781
+ if not any(boxes_overlap(container['box'], other['box']) for other, _ in selected):
1782
+ selected.append((container, children))
1783
+ if len(selected) < 2:
1784
+ return slots + containers
1785
+
1786
+ grouped_slots = {id(slot) for _, children in selected for slot in children}
1787
+ nested_by_container = {}
1788
+ for container, _ in selected:
1789
+ nested_by_container[id(container)] = [
1790
+ other for other in containers
1791
+ if other is not container and overlap_ratio(container['box'], other['box']) >= 0.9
1792
+ ]
1793
+ grouped_containers = {
1794
+ id(container)
1795
+ for container, _ in selected
1796
+ for container in [container] + nested_by_container[id(container)]
1797
+ }
1798
+ out = [slot for slot in slots if id(slot) not in grouped_slots]
1799
+ out += [container for container in containers if id(container) not in grouped_containers]
1800
+ for container, children in selected:
1801
+ children = children + nested_by_container[id(container)]
1802
+ children = sorted(children, key=lambda slot: (slot['box'][1], slot['box'][0]))
1803
+ gaps = [children[i + 1]['box'][1]
1804
+ - (children[i]['box'][1] + children[i]['box'][3])
1805
+ for i in range(len(children) - 1)]
1806
+ outer = container['box']
1807
+ insets = [
1808
+ min(child['box'][1] - outer[1] for child in children),
1809
+ min(outer[0] + outer[2] - child['box'][0] - child['box'][2] for child in children),
1810
+ min(outer[1] + outer[3] - child['box'][1] - child['box'][3] for child in children),
1811
+ min(child['box'][0] - outer[0] for child in children),
1812
+ ]
1813
+ padding = max(0, round(min(insets)))
1814
+ css = container.get('css') or ''
1815
+ if padding:
1816
+ css = '; '.join(part for part in (
1817
+ css.rstrip('; '), 'box-sizing: border-box', 'padding: %dpx' % padding) if part)
1818
+ out.append({
1819
+ 'role': 'group',
1820
+ 'type': 'group',
1821
+ 'box': outer,
1822
+ 'css': css,
1823
+ 'gap': max(0, round(min(gaps))) if gaps else 0,
1824
+ 'items': children,
1825
+ })
1826
+ return out
1827
+
1828
+
1530
1829
  def structure_facts(archetypes, d, shapes):
1531
1830
  """每个页型的**结构事实**:栅格、垂直间距序列、容器样式配方、样张里的实际字数。
1532
1831
 
@@ -1555,8 +1854,7 @@ def structure_facts(archetypes, d, shapes):
1555
1854
  'fill': fill, 'line': line, 'fx': fx, 'shapes': set()})
1556
1855
  g['n'] += 1
1557
1856
  g['parts'].add(s.get('part'))
1558
- if s.get('radius_px'):
1559
- g['radii'].append(s['radius_px'])
1857
+ g['radii'].append(s.get('radius_px') or 0)
1560
1858
  g['shapes'].add(id(s))
1561
1859
  ranked = sorted(groups.values(), key=lambda g: -g['n'])
1562
1860
  recipe_id = {}
@@ -1598,7 +1896,9 @@ def structure_facts(archetypes, d, shapes):
1598
1896
 
1599
1897
  def emit_layouts(archetypes, ldir, busy_hints=None, facts=None, recipes=None):
1600
1898
  prefilled = sum(1 for a in archetypes if a.get('zh'))
1601
- L = ['# 只改 names / roles / bg_rules 三段(都是扁平键值,改完 package.py 自动并回各页型)。',
1899
+ L = ['# 判断单草案 —— package.py 读它产出 layouts.md,deck 的版式坐标从 layouts.md 读。',
1900
+ '# 只改 names / roles / text_roles / layout_modes / bg_rules 五段(都是扁平键值,'
1901
+ '改完 package.py 自动并回各页型)。',
1602
1902
  '# 下面 layouts 段是普查数值,一个字都不要动——改它容易连带删掉 slots/confidence。']
1603
1903
  if prefilled:
1604
1904
  L.append('# names 已按模板自带的版式名填好 %d 条,读一遍确认表意即可,通常不用改。' % prefilled)
@@ -1627,6 +1927,32 @@ def emit_layouts(archetypes, ldir, busy_hints=None, facts=None, recipes=None):
1627
1927
  len([s for s in a['slots'] if not s.get('asset')]),
1628
1928
  '/'.join(str(x) for x in szs[:5]) or '未声明',
1629
1929
  a.get('pic_n') or 0, ';有满屏底图' if a.get('bg_raw') else ''))
1930
+ text_role_ids = {}
1931
+ for a in archetypes:
1932
+ index = 0
1933
+ for slot in a.get('slots') or []:
1934
+ if not slot.get('_needs_role'):
1935
+ continue
1936
+ index += 1
1937
+ text_role_ids[id(slot)] = '%s-text-%d' % (a['name'], index)
1938
+ if text_role_ids:
1939
+ L.append('text_roles: # 取值 title|subtitle|header|footer|body;只改角色,不删槽')
1940
+ for a in archetypes:
1941
+ for slot in a.get('slots') or []:
1942
+ role_id = text_role_ids.get(id(slot))
1943
+ if not role_id:
1944
+ continue
1945
+ L.append(' %s: TODO文本角色 # 来源 %s;占位符 %s;样例 %s;'
1946
+ 'box %s;字号 %s;css %s'
1947
+ % (role_id, slot.get('_source_layer') or '-',
1948
+ slot.get('_placeholder') or '-', q(slot.get('txt') or ''),
1949
+ slot.get('box'), round(slot.get('sz') or 0),
1950
+ q(slot.get('css') or '未声明')))
1951
+ flow_archetypes = [a for a in archetypes if a.get('flow')]
1952
+ if flow_archetypes:
1953
+ L.append('layout_modes: # 取值 flow|slots;内容会变的内容页优先 flow,固定构图页用 slots')
1954
+ for a in flow_archetypes:
1955
+ L.append(' %s: TODO布局模式 # 依据见 layouts 段该页型上方的结构事实' % a['name'])
1630
1956
  # 禁放区是**背景图**的属性,不是页型的属性——按背景资产分组,页型再多也不涨
1631
1957
  bgs = []
1632
1958
  for a in archetypes:
@@ -1679,6 +2005,13 @@ def emit_layouts(archetypes, ldir, busy_hints=None, facts=None, recipes=None):
1679
2005
  % '、'.join('%s=%d字' % (b, n) for b, n in fx['chars'][:6]))
1680
2006
  if fx.get('recipes'):
1681
2007
  L.append(' # 命中配方:%s' % '、'.join(fx['recipes'][:4]))
2008
+ # 槽与槽在坐标上重叠:PPT 里占位符互相压是常态(文字 valign 居中、样张只有一行,
2009
+ # 看不出来),照抄坐标做成 HTML 后内容一变长就撞。实测封面 title 框比 subtitle
2010
+ # 的顶还低 41px,两行标题直接压在副标题上。这里只报事实,怎么让开由你定。
2011
+ ov = slot_overlaps(a.get('slots') or [])
2012
+ if ov:
2013
+ L.append(' # 槽位重叠:%s(模板里靠文字居中不显形,内容变长会撞)'
2014
+ % '、'.join(ov[:3]))
1682
2015
  L.append(' %s:' % a['name'])
1683
2016
  if a.get('role'):
1684
2017
  L.append(' role: %s' % a['role'])
@@ -1686,8 +2019,6 @@ def emit_layouts(archetypes, ldir, busy_hints=None, facts=None, recipes=None):
1686
2019
  L.append(' background: %s' % a['bg'])
1687
2020
  fl = a.get('flow')
1688
2021
  if fl:
1689
- L.append(' # ↓ flow 与 slots 二选一:内容长度会变的页用 flow(区带依次排、'
1690
- '高度由内容定、下面的自动被推下去),构图固定的页用 slots。删掉不要的那个。')
1691
2022
  L.append(' flow:')
1692
2023
  L.append(' top: %d' % fl['top'])
1693
2024
  L.append(' margin: [%d, %d]' % tuple(fl['margin']))
@@ -1698,6 +2029,10 @@ def emit_layouts(archetypes, ldir, busy_hints=None, facts=None, recipes=None):
1698
2029
  L.append(' - kind: grid')
1699
2030
  L.append(' cols: %d' % r['cols'])
1700
2031
  L.append(' gap: [%d, %d]' % tuple(r['gap']))
2032
+ if r.get('margin'):
2033
+ L.append(' margin: [%d, %d] # 本区带自己的左右边距,'
2034
+ '和整页 margin 不同(居中卡片组不跟标题的左边距)'
2035
+ % tuple(r['margin']))
1701
2036
  elif r['kind'] == 'free':
1702
2037
  L.append(' - kind: free # 推不出规整结构,按 slots 的坐标摆')
1703
2038
  else:
@@ -1705,6 +2040,32 @@ def emit_layouts(archetypes, ldir, busy_hints=None, facts=None, recipes=None):
1705
2040
  L.append(' gap: %d' % r['gap'])
1706
2041
  L.append(' items:')
1707
2042
  for s in r['items']:
2043
+ if s.get('type') == 'group':
2044
+ L.append(' - role: group')
2045
+ L.append(' gap: %d' % s['gap'])
2046
+ if s.get('css'):
2047
+ L.append(' css: "%s"'
2048
+ % str(s['css']).replace('"', "'"))
2049
+ L.append(' items:')
2050
+ for child in s['items']:
2051
+ role_id = text_role_ids.get(id(child))
2052
+ if role_id:
2053
+ L.append(' # text-role: %s' % role_id)
2054
+ if child.get('type') == 'decor':
2055
+ L.append(' - {role: container, css: "%s"}'
2056
+ % str(child.get('css') or '').replace('"', "'"))
2057
+ continue
2058
+ extra = ''
2059
+ if child.get('css') is not None:
2060
+ extra += ', css: "%s"' % str(child['css']).replace('"', "'")
2061
+ if child.get('asset'):
2062
+ extra += ', asset: %s' % child['asset']
2063
+ L.append(' - {role: %s, type: %s%s}'
2064
+ % (child['role'], child['type'], extra))
2065
+ continue
2066
+ role_id = text_role_ids.get(id(s))
2067
+ if role_id:
2068
+ L.append(' # text-role: %s' % role_id)
1708
2069
  # free 区带按坐标摆,而 slots 会被删掉,所以坐标必须写在这里
1709
2070
  bx = ', box: %s' % s['box'] if r['kind'] == 'free' else ''
1710
2071
  if s.get('type') == 'decor':
@@ -1712,22 +2073,22 @@ def emit_layouts(archetypes, ldir, busy_hints=None, facts=None, recipes=None):
1712
2073
  % (bx, (s.get('css') or '').replace('"', "'")))
1713
2074
  continue
1714
2075
  extra = bx
1715
- for k in ('size', 'weight', 'color', 'align', 'valign'):
1716
- if s.get(k) is not None:
1717
- # 色值一律加引号:rgba(...) 里的逗号在 flow map 里是分隔符
1718
- extra += ', %s: %s' % (k, '"%s"' % s[k] if k == 'color' else s[k])
2076
+ if s.get('css') is not None:
2077
+ # CSS 串一律加引号:里面的逗号/冒号在 flow map 里是分隔符
2078
+ extra += ', css: "%s"' % str(s['css']).replace('"', "'")
1719
2079
  if s.get('asset'):
1720
2080
  extra += ', asset: %s' % s['asset']
1721
2081
  L.append(' - {role: %s, type: %s%s}' % (s['role'], s['type'], extra))
1722
2082
  L.append(' slots:')
1723
2083
  for s in a['slots']:
2084
+ role_id = text_role_ids.get(id(s))
2085
+ if role_id:
2086
+ L.append(' # text-role: %s' % role_id)
1724
2087
  extra = ''
1725
2088
  if s.get('asset'):
1726
2089
  extra += ', asset: %s' % s['asset']
1727
- for k in ('size', 'weight', 'color', 'align', 'valign'):
1728
- if s.get(k) is not None:
1729
- v = s[k]
1730
- extra += ', %s: %s' % (k, '"%s"' % v if k == 'color' else v)
2090
+ if s.get('css') is not None:
2091
+ extra += ', css: "%s"' % str(s['css']).replace('"', "'")
1731
2092
  L.append(' - {role: %s, box: %s, type: %s%s}'
1732
2093
  % (s['role'], s['box'], s['type'], extra))
1733
2094
  if a.get('decor'):
@@ -1754,7 +2115,7 @@ def emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, l
1754
2115
 
1755
2116
  L = ['## Overview', '',
1756
2117
  'TODO: 两三句话讲清这套模板的性格与适用场景——看过联系表和页面重建图之后再写。', '']
1757
- L.append(('模板自带 %d 种版式,页型、坐标、字号、色值都直读自版式层。'
2118
+ L.append(('模板自带 %d 种版式,页型、坐标和 CSS 样式都直读自版式层。'
1758
2119
  % len(archetypes)) if (d.get('form_hint') or {}).get('form') == 3 else
1759
2120
  ('%d 页样张归纳出 %d 种页型。' % (d['counts']['slides'], len(archetypes))))
1760
2121
  L += ['', '## Usage', '',
@@ -1769,27 +2130,43 @@ def emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, l
1769
2130
  % sidecar,
1770
2131
  '3. **按页型给的形态落元素** —— 页型给 `flow` 就用流式,给 `slots` 就用绝对,'
1771
2132
  '两者只会出现一个。'
1772
- '**flow**:整块用一个纵向 flex 容器,`top` 是它的起始 y,`margin` 是左右边距,'
2133
+ '**flow**:整块用一个纵向 flex 容器,`top` 是它的起始 y,`margin` 是整块的左右边距,'
1773
2134
  '`gap` 是区带之间的间距;`regions` 从上往下依次排,**每个区带的高度由它自己的'
1774
2135
  '内容决定,不要写死高度**——上面的区带内容变多时,下面的自然被推下去,这正是'
1775
2136
  '这套表达要解决的事。区带内部:`kind: grid` 用 `grid-template-columns: repeat(cols, 1fr)` '
1776
2137
  '配 `gap: [行间距, 列间距]`;`kind: stack` 用纵向 flex 配 `gap`;`kind: free` '
1777
- '按该页型 `slots` 里的坐标绝对定位。`role: container` 的项是容器,把它的 `css` '
1778
- '原样写进 style,内容放进去。',
2138
+ ' item 自带的 `box` 绝对定位。区带自带 `margin: [左, 右]` 时用它的、'
2139
+ '覆盖整块的 `margin`(模板里居中的卡片组和贴左的标题横向范围本就不同);'
2140
+ '没带就用整块的 `margin`。`grid` 在自己这份左右边距里再 `repeat(cols, 1fr)`。'
2141
+ '`grid` 里的 `role: group` 是一张卡片:'
2142
+ 'group 的 `css` 用于外层容器,内部 `items` 按顺序纵向排布并使用 group 的 `gap`。'
2143
+ '每个 `role: container` 的项是容器,把它的 `css` 逐项原样写进 style,内容放进去;'
2144
+ '其中没有 `border-radius` 就按 `0`,不得自行补圆角。',
1779
2145
  '4. **按 slot 落元素(页型给的是 slots 时)** —— 每个 slot 渲染成一个绝对定位元素:`box` 是 '
1780
- '`[x, y, w, h]`(%dx%d 画布上的绝对像素),字号取 slot 的 `size`,'
1781
- '字重取 `weight`,颜色取 `color`,对齐取 `align` / `valign`。'
2146
+ '`[x, y, w, h]`(%dx%d 画布上的绝对像素),机械展开成 `left/top/width/height`;'
2147
+ 'slot `css` 是模板排版属性已转译好的声明串,原样写进 style,不要另选字号、'
2148
+ '内边距、颜色或对齐。'
1782
2149
  '带 `asset` 的 slot 是图片元素(logo、角标),把该资产放在它自己的 `box` 里;'
1783
2150
  '这个页型没有 `asset` 槽,这一页就不出现该资产。' % (canvas[0], canvas[1]),
1784
2151
  '5. **铺装饰几何** —— 页型的 `decor` 是这一页的图形骨架(图标托底的圆、'
1785
- '卡片、分隔线):每条渲染成一个绝对定位空元素,`box` 给位置,`css` 原样写进 style,'
1786
- '`geom: ellipse` 另加 `border-radius: 50%`。它们压在背景之上、slot 之下,'
2152
+ '卡片、分隔线):每条渲染成一个绝对定位空元素,`box` 给位置,`css` 逐项原样写进 '
2153
+ 'style;没有 `border-radius` 就按 `0`。只有 `geom: ellipse` 另加 '
2154
+ '`border-radius: 50%`。它们压在背景之上、slot 之下,'
1787
2155
  '落在 slot 上的图标正是靠它们托住。',
1788
- '6. **配色与字体** —— 色板见下面 Colors 段,字体栈与 `@import` 见 Typography 段。']
2156
+ '6. **落实全局设计** —— `design.md` frontmatter `colors`、`typography`、'
2157
+ '`spacing`、`rounded`、`components` 是全局 token;用 CSS variables、类名或内联'
2158
+ '样式承载。局部 slot / decor 的 `css` 优先,不能再解释成另一套视觉系统。'
2159
+ '字体使用 Typography 的完整栈与降级,不在运行时安装字体或依赖。',
2160
+ '7. **保持标题结构** —— 有合适页型可参考时,沿用该页型已有的标题层级与局部 '
2161
+ '`css`;只渲染该页型已有的文字槽,背景中已经可见的固定标题不再创建文本,'
2162
+ '页型没有 `subtitle` 槽就不新增副标题。没有合适参考时,按本包整体视觉组织标题。']
1789
2163
  if assets:
1790
2164
  L += ['', '资产文件(背景由页型的 `background` 字段指定,'
1791
2165
  '图片资产的位置由该页型 `slots` 里带 `asset` 的槽给出):', '',
1792
- '{{ASSET_TABLE}}']
2166
+ '{{ASSET_TABLE}}', '',
2167
+ '将包内 `assets/` 复制到项目内相对目录,再引用复制后的路径;最终 HTML 不引用'
2168
+ '抽取工作目录或本机绝对路径。附件只提供 `assetRoot` / `assetPaths` 时,把'
2169
+ '`assetRoot` 当作不透明前缀,只拼接清单中声明的相对路径。']
1793
2170
  L += ['', '文字与容器的外接矩形落在该页型 `background` 对应的 `text_safe` 内,'
1794
2171
  '避开 `avoid` 列出的区域(两者都在 %s 的 `backgrounds` 段)。内容装不下时换页型或拆页。'
1795
2172
  % sidecar, '',
@@ -1804,7 +2181,8 @@ def emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, l
1804
2181
  ',源为商业/内部字体无 web 分发源,按气质降级到 %s' % f['stack'][1]
1805
2182
  if len(f['stack']) > 1 else ''))
1806
2183
  L += ['', '字号轴:' + '、'.join('%s %dpx' % (k, round(v['sz_px'])) for k, v in roles.items())
1807
- + '。slot 自带 `size` 时以 slot 为准;层级在轴上没有的,复用最接近的一档。', '',
2184
+ + '。slot 自带 `css` 时以其中的 `font-size` 为准;没有 slot CSS 的新增层级,'
2185
+ '复用轴上最接近的一档。', '',
1808
2186
  '字体加载(**HARD REQUIREMENT:下面这行 @import 原样写入全局样式首行,禁止替换为 '
1809
2187
  'fonts.googleapis.com 或其他域**):', '', '```', imp, '```', '',
1810
2188
  '镜像只保证 wght 400 一档,更粗的字重由浏览器合成,字重不能作为唯一区分手段;'
@@ -1827,7 +2205,15 @@ def emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, l
1827
2205
  % (logo['id'], '、'.join('`%s`' % x for x in with_logo) or '(无)'))
1828
2206
  L += ['- 坐标、字号、色值、资产位置以 %s 为准;本文件的 Colors / Typography 是可用值的清单。'
1829
2207
  % sidecar,
1830
- '- 色板里没有绿/红这类语义色时,正负用同族颜色的深浅或透明度区分——先看 Colors 段确认。',
2208
+ '- 强调色族以 Colors 和 %s 的 slot CSS 为主;必要时可以使用 Colors 之外的颜色,'
2209
+ '但不能形成与模板主色竞争的第二强调色。' % sidecar,
2210
+ '- 新增颜色应与模板整体的色相、明度和饱和度关系协调。允许新增中性色、低彩度辅助色'
2211
+ '或局部语义色表达正负、风险、警告、状态、图表序列,但保持辅助层级;'
2212
+ '只要新色通过高饱和、高对比、大面积或跨页重复获得主视觉权重,'
2213
+ '或被用于标题、关键数字、图表主序列、卡片底色或渐变,就属于新的强调色,改用模板'
2214
+ '强调色族的深浅、透明度,或改用线型、纹理、标签区分。',
2215
+ '- 交付前逐页检查:色板、字体、版式、背景、资产和本段规则均来自本风格包;'
2216
+ '页面无资源加载失败、内容溢出或画幅裁切。',
1831
2217
  '- 本包里的数值就是普查结果,照用即可,无需重新统计颜色、字体或版式。',
1832
2218
  '- 风格包以文本形式(zip 摘要等)到手时,直接用摘要里 design.md / layouts.md 的文本。',
1833
2219
  '', '## Exceptions', '']
@@ -1841,9 +2227,9 @@ def emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, l
1841
2227
 
1842
2228
  def emit_brief(d, ctx, ldir):
1843
2229
  (tokens, rest, fonts, roles, assets, rejected, todos, archetypes, cands, sheet,
1844
- leftover, lsheet) = ctx
2230
+ leftover, lsheet, sheet_n) = ctx
1845
2231
  canvas = d['canvas']['px']
1846
- L = ['# 抽取简报(草案已生成,读完这一页就能改)', '',
2232
+ L = ['# 抽取简报(第 1/3 步产物;改完草案跑 package.py 出包)', '',
1847
2233
  '源:`%s` 画布 %dx%d %d 页 / %d 版式 主题 %s form=%s'
1848
2234
  % (d['source']['filename'], canvas[0], canvas[1], d['counts']['slides'],
1849
2235
  d['counts']['layouts'], d['theme_topology']['themes'],
@@ -1878,16 +2264,20 @@ def emit_brief(d, ctx, ldir):
1878
2264
  for t in todos:
1879
2265
  L.append('- ' + t)
1880
2266
  L += ['', '## 联系表(一次看完所有候选图)', '',
1881
- '`l-out/contact-sheet.png` —— 编号对应下表;看完再决定 logo / 封面归属。' if sheet
2267
+ '`l-out/contact-sheet.png` —— 图格编号对应下表前几行;看完再决定 logo / 封面归属。' if sheet
1882
2268
  else '(Pillow 不可用,未生成联系表;逐张看 `media-out/`)', '',
1883
2269
  '| # | 文件 | 尺寸 | 出现 | 满屏 | 页 | 草案判定 |', '|---|---|---|---|---|---|---|']
1884
2270
  decided = {a['src']['file']: a['id'] for a in assets}
1885
2271
  why = {c['file']: r for c, r in rejected}
1886
- for i, c in enumerate(cands[:12], 1):
2272
+ for i, c in enumerate(cands, 1):
1887
2273
  L.append('| %d | `%s` | %sx%s | %d | %s | %s | %s |' % (
1888
2274
  i, c['file'], c['probe'].get('w') or '?', c['probe'].get('h') or '?', c['n'],
1889
2275
  'Y' if c['fullscreen'] else '', ','.join(map(str, c['slides'][:6])) or 'layout',
1890
2276
  decided.get(c['file']) or ('✗ ' + why.get(c['file'], '未采纳'))))
2277
+ if len(cands) > sheet_n:
2278
+ L.append('')
2279
+ L.append('拼版图只含前 %d 张(第 %d 行之后的没有图格)。要看后面某张,'
2280
+ '按文件名直接看 `media-out/`。' % (sheet_n, sheet_n))
1891
2281
  L += ['', '## 颜色(草案 token 已写进 frontmatter.yaml)', '',
1892
2282
  '| token | hex | 出现 |', '|---|---|---|']
1893
2283
  for name, r in tokens:
@@ -1913,7 +2303,7 @@ def emit_brief(d, ctx, ldir):
1913
2303
  if leftover:
1914
2304
  L += ['', '未归入 archetype 的页:%s —— 都是单页孤例,需要就自己补一个 archetype。'
1915
2305
  % ', '.join(map(str, leftover))]
1916
- L += ['', '各 archetype 的 slot 原文(据此起中文页型名、改 role):', '']
2306
+ L += ['', '各 archetype 的 slot 原文(据此起中文页型名,并在 text_roles 判断文本角色):', '']
1917
2307
  for a in archetypes:
1918
2308
  L.append('- `%s`(第 %s 页,覆盖 %s)' % (a['name'], a['rep'], a['pages']))
1919
2309
  for s in a['slots']:
@@ -1939,9 +2329,18 @@ def main(argv=None):
1939
2329
  tokens, rest, rows = draft_colors(d, cusage)
1940
2330
  fonts = draft_fonts(d)
1941
2331
  archetypes, pages, leftover = draft_layouts(d, outdir)
2332
+ # 封面底图:form=3 的页型键就是角色名(cover/section/...),直接按名字取。
2333
+ # form=2 按样张聚类,键是 layout-1..N,永远匹配不上 'cover'——实测 vo-lite 因此
2334
+ # 一张 role: cover 都没有,封面主视觉被标成 bg-content-1,消费端拿不到封面资产,
2335
+ # design.md 的「封面底图必用 cover 资产」这条硬规则无从满足。回退到覆盖第 1 页的
2336
+ # 那个页型:deck 的第 1 页就是封面,这是版式无关的事实。
1942
2337
  cover_media = next((a['bg_raw'] for a in archetypes if a['name'] == 'cover'), None)
1943
- bg_needed = {a['bg_raw'] for a in archetypes if a['bg_raw'] and a['bg_raw'].startswith('ppt/media')}
1944
- bg_under = {p['no']: p['bg_media'] for p in pages}
2338
+ if not cover_media:
2339
+ cover_media = next((a['bg_raw'] for a in archetypes
2340
+ if 1 in (a.get('pages') or ())), None)
2341
+ exported_media = {m['media'] for m in d.get('media', []) if m.get('exported')}
2342
+ bg_needed = {a['bg_raw'] for a in archetypes if a['bg_raw'] in exported_media}
2343
+ bg_under = {p['no']: p.get('rendered_bg') or p['bg_media'] for p in pages}
1945
2344
  assets, rejected, todos, alias, pool = draft_assets(d, outdir, bg_needed, cover_media, bg_under)
1946
2345
  media_to_asset = {a['src']['media']: a['id'] for a in assets}
1947
2346
  for m, w in (alias or {}).items():
@@ -1995,9 +2394,23 @@ def main(argv=None):
1995
2394
  a['slots'] = keep
1996
2395
  roles = draft_scale(d, archetypes)
1997
2396
  slot_added = cover_slot_colors(tokens, archetypes, rows, cusage)
1998
- cands = sorted([c for c in [a['src'] for a in assets]] +
1999
- [c for c, _ in rejected], key=lambda c: (-c['n'], c['file']))
2000
- sheet = contact_sheet(outdir, cands, os.path.join(ldir, 'contact-sheet.png'))
2397
+ # 进包的资产必须全部上联系表。BRIEF L 层「看联系表确认 logo / 封面归属」,
2398
+ # 表上没有的东西它只会从表里另挑一张顶上去。封面主视觉按定义只出现在封面那一页
2399
+ # (n=1),按出现次数排序时排在最末——实测被 cands[:12] 截掉,模型于是把 bg-cover
2400
+ # 换成了已经在用的内容页背景,封面与内容页字节相同,封面主视觉整个丢失。
2401
+ decided_c = sorted([a['src'] for a in assets], key=lambda c: (-c['n'], c['file']))
2402
+ other_c = sorted([c for c, _ in rejected], key=lambda c: (-c['n'], c['file']))
2403
+ cands, seen_file = [], set()
2404
+ for c in decided_c + other_c: # 同一张图可能有多条候选记录(不同位置各一条)
2405
+ if c['file'] not in seen_file:
2406
+ seen_file.add(c['file'])
2407
+ cands.append(c)
2408
+ # 表列全部候选,拼版图只拼前几张:两者成本差着数量级。表是文字,60 行也几乎不占
2409
+ # 上下文,却是模型唯一能知道「存在这张图」的地方——名额砍在这里,被误判成未采纳的
2410
+ # 图连翻案的机会都没有。拼版图是要「看」的,60 格就是 4 列×15 行、降采样后每格
2411
+ # 糊成一团,那个上限才有意义。
2412
+ sheet_items = cands[:max(SHEET_CAP, len(decided_c))]
2413
+ sheet = contact_sheet(outdir, sheet_items, os.path.join(ldir, 'contact-sheet.png'))
2001
2414
  lsheet = layout_sheet(outdir, archetypes, os.path.join(ldir, 'layout-sheet.png'))
2002
2415
 
2003
2416
  anchors = draft_anchors(d, tokens, fonts, roles, assets, archetypes)
@@ -2040,7 +2453,7 @@ def main(argv=None):
2040
2453
  gaps.append('源字体 %s 不在 font-fallback 表里,字体栈只有原名,消费端很可能装不上;'
2041
2454
  '按气质挑一个有 web 分发源的近似体补进栈,不要照抄原名。' % f['names'][0])
2042
2455
  nosize = [(a['name'], s['box']) for a in archetypes for s in a['slots']
2043
- if not s.get('asset') and not s.get('size')]
2456
+ if not s.get('asset') and not s.get('_font_size')]
2044
2457
  if nosize:
2045
2458
  gaps.append('这些文字槽在源文件任何层级都没有字号声明(都不是占位符,是普通文本框,'
2046
2459
  '继承源是 presentation.xml 的 defaultTextStyle,本抽取按约定不解继承链):'
@@ -2068,12 +2481,15 @@ def main(argv=None):
2068
2481
  emit_layouts(archetypes, ldir, busy_hints, facts, recipes)
2069
2482
  emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, ldir)
2070
2483
  emit_brief(d, (tokens, rest, fonts, roles, assets, rejected, todos, archetypes, cands, sheet,
2071
- leftover, lsheet), ldir)
2484
+ leftover, lsheet, len(sheet_items)), ldir)
2072
2485
 
2073
- print('草案就绪 -> %s' % ldir)
2074
- print(' 资产 %d(%s) 版式 %d 色 %d 字体 %d'
2486
+ # 这几行落在模型判断「skill 是不是做完了」的那一刻。只报数就会被读成「包已生成」,
2487
+ # 于是判断和打包整段被跳过,deck 拿不到任何版式坐标。所以这里报进度与下一条命令。
2488
+ print('第 1/3 步完成,判断单草案 -> %s' % ldir)
2489
+ print(' 待你确认:资产 %d(%s) 版式 %d 色 %d 字体 %d'
2075
2490
  % (len(assets), ', '.join(x['id'] for x in assets), len(archetypes), len(tokens), len(fonts)))
2076
- print(' 先读 l-out/BRIEF.md,再看 l-out/contact-sheet.png')
2491
+ print(' 2 步 读 l-out/BRIEF.md contact-sheet.png,改掉草案里的 TODO')
2492
+ print(' 第 3 步 package.py 产出 design.md + layouts.md —— deck 的版式坐标只从这两份读')
2077
2493
  sys.stdout.flush()
2078
2494
  return 0
2079
2495