@lark-apaas/coding-steering 0.1.18-dev.e6a2787 → 0.1.18-dev.ec7b8c1

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.
@@ -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
@@ -264,9 +265,9 @@ OFFICE_DEFAULT_FONTS_NORM = {norm(x) for x in OFFICE_DEFAULT_FONTS}
264
265
 
265
266
 
266
267
  def cover_slot_colors(tokens, archetypes, rows, cusage):
267
- """slot 里出现的每个色值都必须在色板里有名字。
268
+ """slot CSS 里出现的每个色值都必须在色板里有名字。
268
269
 
269
- Hard Rules 写「颜色只用 colors 里的 token」,而 slot 的 color 是从模板直读的,
270
+ Hard Rules 写「颜色只用 colors 里的 token」,而 slot CSS 的 color 是从模板直读的,
270
271
  两者不对齐就等于产物自己违反自己的规则——slot 的色值直读自模板,未必都已进
271
272
  色板。这里把缺的补进色板,按用法归族命名。
272
273
  """
@@ -286,7 +287,7 @@ def cover_slot_colors(tokens, archetypes, rows, cusage):
286
287
  added = []
287
288
  for a in archetypes:
288
289
  for s in a['slots']:
289
- h = (s.get('color') or '').upper()
290
+ h = (s.get('_color') or '').upper()
290
291
  if not h.startswith('#') or h in have:
291
292
  continue
292
293
  have.add(h)
@@ -500,7 +501,8 @@ def draft_anchors(d, tokens, fonts, roles, assets, archetypes):
500
501
  # 6. 层级:字号跨度 + 字重是否单一(字重真单一才敢说「不靠字重」)
501
502
  disp, body = roles.get('display'), roles.get('body')
502
503
  if disp and body and disp['sz_px'] > body['sz_px']:
503
- 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')}
504
506
  tail = (',字重只用 %s 一档' % list(ws)[0]) if len(ws) == 1 else ''
505
507
  A.append(('size-driven-hierarchy', 'pattern',
506
508
  '最大字号档与正文档相差 %.1f 倍(见 typography)%s'
@@ -930,11 +932,23 @@ def slot_overlaps(slots):
930
932
  return out
931
933
 
932
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
+
933
943
  def slot_style(s):
934
- """占位符自带的排版样式——字号/色值/对齐/字重都是直读,不给消费端留编的空间。
944
+ """占位符自带的排版样式,统一转成可直接写进 HTML style 的 CSS 声明串。
935
945
 
936
946
  样式可能在三层:lstStyle.lvl1pPr(版式占位符常用)、段落 defRPr(Mac Office
937
947
  导出把大量属性写在这一层)、段落 pPr(对齐)。逐层兜底,缺一层就往下取。
948
+
949
+ `box` 是布局几何,继续由 slot 独立承载;其余渲染属性不再泄漏成 size / color /
950
+ align / insets_px 等 PPTX 中间字段。下划线开头的键仅供 draft 内部统计,emit_layouts
951
+ 不会写进消费者产物。
938
952
  """
939
953
  txt = s.get('text') or {}
940
954
  ls = dict((txt.get('lstStyle') or {}).get('lvl1pPr') or {})
@@ -955,12 +969,41 @@ def slot_style(s):
955
969
  anysz = shape_sz(s)
956
970
  if anysz:
957
971
  ls['sz_px'] = anysz
972
+ body = txt.get('bodyPr') or {}
973
+ css = []
958
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
+ ))
959
984
  if ls.get('sz_px'):
960
- out['size'] = round(ls['sz_px'])
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']))
961
1003
  col = (ls.get('color') or {}).get('resolved')
962
1004
  if col:
963
- out['color'] = col
1005
+ css.append('color: %s' % col)
1006
+ out['_color'] = col
964
1007
  else:
965
1008
  # 占位符的字色也可以是 gradFill(章节页的大号序号常这么做)。解析层已经把
966
1009
  # stops 和角度记全了,这里只取单色就会整条丢掉,消费端只能自己编一个平色。
@@ -969,17 +1012,30 @@ def slot_style(s):
969
1012
  if f.get('type') == 'gradient':
970
1013
  g = _load_query()._css_gradient(f)
971
1014
  if g:
972
- out['css'] = ('background-image: %s; -webkit-background-clip: text; '
973
- 'background-clip: text; color: transparent' % g)
974
- if ls.get('weight'):
975
- out['weight'] = ls['weight']
976
- elif ls.get('bold'):
977
- out['weight'] = 700
978
- if ls.get('algn') and ls['algn'] not in ('l', 'just'):
979
- out['align'] = {'ctr': 'center', 'r': 'right'}.get(ls['algn'], ls['algn'])
980
- anchor = ((s.get('text') or {}).get('bodyPr') or {}).get('anchor')
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')
981
1028
  if anchor in ('ctr', 'b'):
982
- out['valign'] = {'ctr': 'middle', 'b': 'bottom'}[anchor]
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)
983
1039
  return out
984
1040
 
985
1041
 
@@ -1021,7 +1077,8 @@ def layouts_from_template(d, shapes, cW, cH):
1021
1077
  """
1022
1078
  by_part = defaultdict(list)
1023
1079
  for s in shapes:
1024
- if s.get('layer') == 'layout' and s.get('ph'):
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))):
1025
1082
  by_part[s['part']].append(s)
1026
1083
  bg_of_layout = {}
1027
1084
  composites = d.get('background_composites') or {}
@@ -1050,10 +1107,10 @@ def layouts_from_template(d, shapes, cW, cH):
1050
1107
  slots, seen_kind = [], set()
1051
1108
  for s in phs:
1052
1109
  t = PH_TO_TYPE.get((s['ph'] or {}).get('type'), 'body')
1053
- if t in ('slide-number', 'footer'):
1054
- continue # 页码/页脚属 chrome,不是内容槽
1110
+ if t in ('slide-number', 'footer') and not shape_text(s):
1111
+ continue # chrome 占位符不是实际元素
1055
1112
  b = s['box']
1056
- role = t if t in ('title', 'subtitle') else 'body'
1113
+ role = t if t in ('title', 'subtitle', 'footer', 'slide-number') else 'body'
1057
1114
  if t == 'title' and 'title' in seen_kind:
1058
1115
  role, t = 'subtitle', 'subtitle'
1059
1116
  seen_kind.add(t)
@@ -1062,6 +1119,14 @@ def layouts_from_template(d, shapes, cW, cH):
1062
1119
  round(b.get('w', 0)), round(b.get('h', 0))],
1063
1120
  'txt': shape_text(s) or (s.get('name') or '')[:24]}
1064
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
+ })
1065
1130
  slots.append(row)
1066
1131
  # 非满屏的图片元素(logo / 联名标 / 装饰)——它们逐版式换位置换尺寸,
1067
1132
  # 必须按版式落进 slots,压成一条全局「固定位」规则就会撞标题。
@@ -1181,17 +1246,76 @@ def collect_decor(shapes, part, taken_boxes, canvas, limit=10):
1181
1246
  return out[:limit] # 同款不同位置都要留,位置本身是版式信息
1182
1247
 
1183
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
+
1184
1304
  def draft_layouts(d, outdir):
1185
- shapes = json.load(open(os.path.join(outdir, 'ref', 'shapes.json'), encoding='utf-8'))['shapes']
1305
+ with open(os.path.join(outdir, 'ref', 'shapes.json'), encoding='utf-8') as stream:
1306
+ shapes = json.load(stream)['shapes']
1186
1307
  cW, cH = d['canvas']['px']
1187
1308
  if (d.get('form_hint') or {}).get('form') == 3:
1188
1309
  arch = layouts_from_template(d, shapes, cW, cH)
1189
1310
  if len(arch) >= 3:
1190
1311
  return arch, [], []
1191
1312
  by_slide = defaultdict(list)
1313
+ by_layout = defaultdict(list)
1192
1314
  for s in shapes:
1193
1315
  if s.get('layer') == 'slide':
1194
1316
  by_slide[s['part']].append(s)
1317
+ elif s.get('layer') == 'layout':
1318
+ by_layout[s['part']].append(s)
1195
1319
 
1196
1320
  bg_of_slide, layout_of_slide = {}, {}
1197
1321
  for s in d.get('slides', []):
@@ -1207,32 +1331,43 @@ def draft_layouts(d, outdir):
1207
1331
 
1208
1332
  pages = []
1209
1333
  for part, sh in sorted(by_slide.items(), key=lambda kv: slide_no(kv[0])):
1334
+ layout_part = layout_of_slide.get(part)
1335
+ layout_shapes = by_layout.get(layout_part) or []
1210
1336
  bg_media = top_bleed_media(sh)
1211
1337
  if bg_media is None:
1212
- bg_media = bg_of_layout.get(layout_of_slide.get(part))
1338
+ bg_media = bg_of_layout.get(layout_part)
1213
1339
  rendered_bg = (composites.get(part)
1214
- or composites.get(layout_of_slide.get(part))
1340
+ or composites.get(layout_part)
1215
1341
  or bg_media)
1216
1342
  texts = []
1217
- for s in sh:
1218
- if s.get('kind') != 'sp':
1219
- continue
1220
- txt = shape_text(s)
1221
- if not txt:
1222
- continue
1343
+ for s, source_layer in inherited_text_shapes(layout_shapes, sh):
1344
+ txt = shape_text(s) or (s.get('name') or '')[:24]
1223
1345
  b = s.get('box') or {}
1224
1346
  if b.get('w', 0) < DECOR_MIN or b.get('h', 0) < 16:
1225
1347
  continue
1226
- texts.append({'sz': shape_sz(s), 'box': b, 'txt': txt, 'style': slot_style(s)})
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
+ })
1227
1361
  texts.sort(key=lambda t: (-t['sz'], t['box'].get('y', 0)))
1228
- pics = [s for s in sh if s.get('kind') == 'pic' and s.get('w_pct', 0) < 95]
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]
1229
1364
  # 小图元素(logo / 角标 / 装饰)逐页记位置,供 archetype 落 slots
1230
1365
  marks = [{'media': s['media'], 'box': s['box']} for s in pics
1231
1366
  if s.get('media') and (s.get('box') or {}).get('w') and s.get('w_pct', 0) < 30]
1232
1367
  pages.append({'part': part, 'no': slide_no(part), 'bg_media': bg_media,
1233
1368
  'rendered_bg': rendered_bg,
1234
1369
  'bg_color': bg_of_slide.get(part), 'texts': texts, 'pic_n': len(pics),
1235
- 'marks': marks, 'shape_n': len(sh)})
1370
+ 'marks': marks, 'shape_n': len(visible_shapes), 'layout': layout_part})
1236
1371
 
1237
1372
  # 页型的**角色**(封面 / 章节页 / 内容页……)不在这里判:那是看图才能下的结论,
1238
1373
  # 交给读得到重建图的模型。脚本只做客观归并——同一张底图 + 文字块数量相近的页
@@ -1286,12 +1421,13 @@ def draft_layouts(d, outdir):
1286
1421
  rest.sort(key=lambda t: (t['box'].get('y', 0), t['box'].get('x', 0)))
1287
1422
  ordered = ([title] if title else []) + rest
1288
1423
  slots = []
1289
- note_truncation('文字槽', 6, len(ordered),
1290
- '需要更多同类槽时,按已有同类槽的间距等距延续,不要另起一套网格'
1291
- '——包里的坐标是模板量出来的,自创网格等于放弃这套版式', name)
1292
- for i, t in enumerate(ordered[:6]):
1424
+ for i, t in enumerate(ordered):
1293
1425
  b = t['box']
1294
- if t is title:
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:
1295
1431
  role = typ = 'title'
1296
1432
  elif (title and i == 1
1297
1433
  # 副标题 = 紧跟在标题下方、与标题左对齐的那一块。三个量都相对标题
@@ -1307,6 +1443,12 @@ def draft_layouts(d, outdir):
1307
1443
  round(b.get('w', 0)), round(b.get('h', 0))],
1308
1444
  'type': typ, 'sz': t['sz'], 'txt': t['txt']}
1309
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
+ })
1310
1452
  slots.append(row)
1311
1453
  # 代表页上的小图元素按位置去重后落 slots(同一 logo 在不同页型位置不同)
1312
1454
  seen_mark = set()
@@ -1320,7 +1462,15 @@ def draft_layouts(d, outdir):
1320
1462
  'media': mk['media'],
1321
1463
  'box': [round(b.get('x', 0)), round(b.get('y', 0)),
1322
1464
  round(b.get('w', 0)), round(b.get('h', 0))]})
1323
- decor = collect_decor(shapes, rep['part'], {tuple(s['box']) for s in slots}, (cW, cH))
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)
1324
1474
  archetypes.append({'name': name, 'bg': None, 'bg_raw': bg_raw, 'slots': slots,
1325
1475
  'decor': decor,
1326
1476
  'pages': sorted(p['no'] for p in ps), 'rep': rep['no'],
@@ -1470,10 +1620,11 @@ def emit_frontmatter(d, tokens, fonts, roles, anchors, gaps, ldir):
1470
1620
  L.append('spacing:')
1471
1621
  L.append(' page-padding: {top: %s, right: %s, bottom: %s, left: %s}'
1472
1622
  % (edge['top'], edge['right'], edge['bottom'], edge['left']))
1473
- # 只排除「圆角量为零」(那是直角不是圆角),不再设「出现几次才算数」的门槛
1474
- radii = [r for r in (d.get('radii_census') or []) if r['px'] >= 1]
1475
- if radii:
1476
- top = max(radii, key=lambda r: r['n'])
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]
1477
1628
  L.append('rounded:')
1478
1629
  L.append(' card: %dpx' % round(top['px']))
1479
1630
  if edges_full:
@@ -1509,9 +1660,13 @@ def draft_flow(a, facts, canvas):
1509
1660
  cW, cH = canvas
1510
1661
  # 装饰件也算进来:很多模板的版式层只有几个占位符,真正撑起版面的是卡片容器
1511
1662
  # (在 decor 里)。只看 slots 会把一页的主体结构整个漏掉。
1512
- items = [s for s in a['slots'] if s.get('box')]
1513
- items += [{'role': 'container', 'type': 'decor', 'box': dcr['box'], 'css': dcr.get('css')}
1514
- for dcr in (a.get('decor') or [])]
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)
1515
1670
  if len(items) < 2:
1516
1671
  return None
1517
1672
  items.sort(key=lambda s: (s['box'][1], s['box'][0]))
@@ -1522,7 +1677,7 @@ def draft_flow(a, facts, canvas):
1522
1677
  return None
1523
1678
  # 区带边界 = 间距分布里的最大空档。同一区带内部的间距(网格行距之类)总是明显
1524
1679
  # 小于区带之间的间距,用本页自己的分布切,不设固定阈值。
1525
- cut = _gap_cut(pos, min(pos), max(pos)) if len(pos) > 1 else max(pos) + 1
1680
+ cut = _gap_cut(pos, min(pos), max(pos)) if len(pos) > 1 else pos[0]
1526
1681
  regions, cur = [], [items[0]]
1527
1682
  for i, g in enumerate(gaps):
1528
1683
  if g >= cut:
@@ -1531,6 +1686,11 @@ def draft_flow(a, facts, canvas):
1531
1686
  cur.append(items[i + 1])
1532
1687
  regions.append(cur)
1533
1688
 
1689
+ # 整页左右边距 = 所有内容的横向外包络,作为各区带的缺省。
1690
+ lefts = [s['box'][0] for s in items]
1691
+ rights = [s['box'][0] + s['box'][2] for s in items]
1692
+ page_margin = [min(lefts), cW - max(rights)]
1693
+
1534
1694
  out = []
1535
1695
  for reg in regions:
1536
1696
  if not reg:
@@ -1553,8 +1713,17 @@ def draft_flow(a, facts, canvas):
1553
1713
  if len(rows) > 1:
1554
1714
  row_gap = round(rows[1][0]['box'][1]
1555
1715
  - (rows[0][0]['box'][1] + rows[0][0]['box'][3]))
1556
- out.append({'kind': 'grid', 'cols': cols, 'gap': [max(col_gap, 0), max(row_gap, 0)],
1557
- 'items': rows[0]})
1716
+ region = {'kind': 'grid', 'cols': cols, 'gap': [max(col_gap, 0), max(row_gap, 0)],
1717
+ 'items': rows[0]}
1718
+ # 卡片组的横向范围常和整页不同(标题贴左、卡片居中)。整页边距是所有元素的
1719
+ # 外包络,直接套给居中卡片组会把它拉偏成左对齐。区带范围和整页明显不一致时,
1720
+ # 落这个区带自己的左右边距,消费端把网格放进它再填 1fr。按落盘的整数比较,
1721
+ # 亚像素噪声不触发多余的区带边距。
1722
+ reg_margin = [min(s['box'][0] for s in rows[0]),
1723
+ cW - max(s['box'][0] + s['box'][2] for s in rows[0])]
1724
+ if [int(reg_margin[0]), int(reg_margin[1])] != [int(page_margin[0]), int(page_margin[1])]:
1725
+ region['margin'] = reg_margin
1726
+ out.append(region)
1558
1727
  elif len(rows) == len(reg):
1559
1728
  # 每行一个元素 = 真的竖着排
1560
1729
  inner = 0
@@ -1565,14 +1734,91 @@ def draft_flow(a, facts, canvas):
1565
1734
  # 每行元素数不一致(比如左列两张、右列一张跨两行)。硬说成 stack 会让消费端
1566
1735
  # 以为它们是竖排的,比不给还糟。如实说这块推不出规整结构,按坐标摆。
1567
1736
  out.append({'kind': 'free', 'items': reg})
1737
+ if fixed:
1738
+ out.append({'kind': 'free', 'items': fixed})
1568
1739
  if len(out) < 2:
1569
1740
  return None
1570
- lefts = [s['box'][0] for s in items]
1571
- rights = [s['box'][0] + s['box'][2] for s in items]
1572
- return {'top': items[0]['box'][1], 'margin': [min(lefts), cW - max(rights)],
1741
+ return {'top': items[0]['box'][1], 'margin': page_margin,
1573
1742
  'gap': round(cut), 'regions': out}
1574
1743
 
1575
1744
 
1745
+ def box_contains(outer, inner):
1746
+ return (outer[0] <= inner[0] and outer[1] <= inner[1]
1747
+ and outer[0] + outer[2] >= inner[0] + inner[2]
1748
+ and outer[1] + outer[3] >= inner[1] + inner[3])
1749
+
1750
+
1751
+ def boxes_overlap(a, b):
1752
+ return (min(a[0] + a[2], b[0] + b[2]) > max(a[0], b[0])
1753
+ and min(a[1] + a[3], b[1] + b[3]) > max(a[1], b[1]))
1754
+
1755
+
1756
+ def overlap_ratio(outer, inner):
1757
+ width = min(outer[0] + outer[2], inner[0] + inner[2]) - max(outer[0], inner[0])
1758
+ height = min(outer[1] + outer[3], inner[1] + inner[3]) - max(outer[1], inner[1])
1759
+ if width <= 0 or height <= 0 or inner[2] <= 0 or inner[3] <= 0:
1760
+ return 0
1761
+ return width * height / (inner[2] * inner[3])
1762
+
1763
+
1764
+ def group_flow_cards(slots, containers):
1765
+ """把并列卡片容器及其文字组成一层 group,避免拍平成多列元素。"""
1766
+ candidates = []
1767
+ for container in containers:
1768
+ children = [slot for slot in slots if box_contains(container['box'], slot['box'])]
1769
+ if len(children) >= 2:
1770
+ candidates.append((container, children))
1771
+ selected = []
1772
+ for container, children in sorted(
1773
+ candidates, key=lambda pair: pair[0]['box'][2] * pair[0]['box'][3]):
1774
+ if not any(boxes_overlap(container['box'], other['box']) for other, _ in selected):
1775
+ selected.append((container, children))
1776
+ if len(selected) < 2:
1777
+ return slots + containers
1778
+
1779
+ grouped_slots = {id(slot) for _, children in selected for slot in children}
1780
+ nested_by_container = {}
1781
+ for container, _ in selected:
1782
+ nested_by_container[id(container)] = [
1783
+ other for other in containers
1784
+ if other is not container and overlap_ratio(container['box'], other['box']) >= 0.9
1785
+ ]
1786
+ grouped_containers = {
1787
+ id(container)
1788
+ for container, _ in selected
1789
+ for container in [container] + nested_by_container[id(container)]
1790
+ }
1791
+ out = [slot for slot in slots if id(slot) not in grouped_slots]
1792
+ out += [container for container in containers if id(container) not in grouped_containers]
1793
+ for container, children in selected:
1794
+ children = children + nested_by_container[id(container)]
1795
+ children = sorted(children, key=lambda slot: (slot['box'][1], slot['box'][0]))
1796
+ gaps = [children[i + 1]['box'][1]
1797
+ - (children[i]['box'][1] + children[i]['box'][3])
1798
+ for i in range(len(children) - 1)]
1799
+ outer = container['box']
1800
+ insets = [
1801
+ min(child['box'][1] - outer[1] for child in children),
1802
+ min(outer[0] + outer[2] - child['box'][0] - child['box'][2] for child in children),
1803
+ min(outer[1] + outer[3] - child['box'][1] - child['box'][3] for child in children),
1804
+ min(child['box'][0] - outer[0] for child in children),
1805
+ ]
1806
+ padding = max(0, round(min(insets)))
1807
+ css = container.get('css') or ''
1808
+ if padding:
1809
+ css = '; '.join(part for part in (
1810
+ css.rstrip('; '), 'box-sizing: border-box', 'padding: %dpx' % padding) if part)
1811
+ out.append({
1812
+ 'role': 'group',
1813
+ 'type': 'group',
1814
+ 'box': outer,
1815
+ 'css': css,
1816
+ 'gap': max(0, round(min(gaps))) if gaps else 0,
1817
+ 'items': children,
1818
+ })
1819
+ return out
1820
+
1821
+
1576
1822
  def structure_facts(archetypes, d, shapes):
1577
1823
  """每个页型的**结构事实**:栅格、垂直间距序列、容器样式配方、样张里的实际字数。
1578
1824
 
@@ -1601,8 +1847,7 @@ def structure_facts(archetypes, d, shapes):
1601
1847
  'fill': fill, 'line': line, 'fx': fx, 'shapes': set()})
1602
1848
  g['n'] += 1
1603
1849
  g['parts'].add(s.get('part'))
1604
- if s.get('radius_px'):
1605
- g['radii'].append(s['radius_px'])
1850
+ g['radii'].append(s.get('radius_px') or 0)
1606
1851
  g['shapes'].add(id(s))
1607
1852
  ranked = sorted(groups.values(), key=lambda g: -g['n'])
1608
1853
  recipe_id = {}
@@ -1645,7 +1890,8 @@ def structure_facts(archetypes, d, shapes):
1645
1890
  def emit_layouts(archetypes, ldir, busy_hints=None, facts=None, recipes=None):
1646
1891
  prefilled = sum(1 for a in archetypes if a.get('zh'))
1647
1892
  L = ['# 判断单草案 —— package.py 读它产出 layouts.md,deck 的版式坐标从 layouts.md 读。',
1648
- '# 只改 names / roles / bg_rules 三段(都是扁平键值,改完 package.py 自动并回各页型)。',
1893
+ '# 只改 names / roles / text_roles / layout_modes / bg_rules 五段(都是扁平键值,'
1894
+ '改完 package.py 自动并回各页型)。',
1649
1895
  '# 下面 layouts 段是普查数值,一个字都不要动——改它容易连带删掉 slots/confidence。']
1650
1896
  if prefilled:
1651
1897
  L.append('# names 已按模板自带的版式名填好 %d 条,读一遍确认表意即可,通常不用改。' % prefilled)
@@ -1674,6 +1920,32 @@ def emit_layouts(archetypes, ldir, busy_hints=None, facts=None, recipes=None):
1674
1920
  len([s for s in a['slots'] if not s.get('asset')]),
1675
1921
  '/'.join(str(x) for x in szs[:5]) or '未声明',
1676
1922
  a.get('pic_n') or 0, ';有满屏底图' if a.get('bg_raw') else ''))
1923
+ text_role_ids = {}
1924
+ for a in archetypes:
1925
+ index = 0
1926
+ for slot in a.get('slots') or []:
1927
+ if not slot.get('_needs_role'):
1928
+ continue
1929
+ index += 1
1930
+ text_role_ids[id(slot)] = '%s-text-%d' % (a['name'], index)
1931
+ if text_role_ids:
1932
+ L.append('text_roles: # 取值 title|subtitle|header|footer|body;只改角色,不删槽')
1933
+ for a in archetypes:
1934
+ for slot in a.get('slots') or []:
1935
+ role_id = text_role_ids.get(id(slot))
1936
+ if not role_id:
1937
+ continue
1938
+ L.append(' %s: TODO文本角色 # 来源 %s;占位符 %s;样例 %s;'
1939
+ 'box %s;字号 %s;css %s'
1940
+ % (role_id, slot.get('_source_layer') or '-',
1941
+ slot.get('_placeholder') or '-', q(slot.get('txt') or ''),
1942
+ slot.get('box'), round(slot.get('sz') or 0),
1943
+ q(slot.get('css') or '未声明')))
1944
+ flow_archetypes = [a for a in archetypes if a.get('flow')]
1945
+ if flow_archetypes:
1946
+ L.append('layout_modes: # 取值 flow|slots;内容会变的内容页优先 flow,固定构图页用 slots')
1947
+ for a in flow_archetypes:
1948
+ L.append(' %s: TODO布局模式 # 依据见 layouts 段该页型上方的结构事实' % a['name'])
1677
1949
  # 禁放区是**背景图**的属性,不是页型的属性——按背景资产分组,页型再多也不涨
1678
1950
  bgs = []
1679
1951
  for a in archetypes:
@@ -1740,8 +2012,6 @@ def emit_layouts(archetypes, ldir, busy_hints=None, facts=None, recipes=None):
1740
2012
  L.append(' background: %s' % a['bg'])
1741
2013
  fl = a.get('flow')
1742
2014
  if fl:
1743
- L.append(' # ↓ flow 与 slots 二选一:内容长度会变的页用 flow(区带依次排、'
1744
- '高度由内容定、下面的自动被推下去),构图固定的页用 slots。删掉不要的那个。')
1745
2015
  L.append(' flow:')
1746
2016
  L.append(' top: %d' % fl['top'])
1747
2017
  L.append(' margin: [%d, %d]' % tuple(fl['margin']))
@@ -1752,6 +2022,10 @@ def emit_layouts(archetypes, ldir, busy_hints=None, facts=None, recipes=None):
1752
2022
  L.append(' - kind: grid')
1753
2023
  L.append(' cols: %d' % r['cols'])
1754
2024
  L.append(' gap: [%d, %d]' % tuple(r['gap']))
2025
+ if r.get('margin'):
2026
+ L.append(' margin: [%d, %d] # 本区带自己的左右边距,'
2027
+ '和整页 margin 不同(居中卡片组不跟标题的左边距)'
2028
+ % tuple(r['margin']))
1755
2029
  elif r['kind'] == 'free':
1756
2030
  L.append(' - kind: free # 推不出规整结构,按 slots 的坐标摆')
1757
2031
  else:
@@ -1759,6 +2033,32 @@ def emit_layouts(archetypes, ldir, busy_hints=None, facts=None, recipes=None):
1759
2033
  L.append(' gap: %d' % r['gap'])
1760
2034
  L.append(' items:')
1761
2035
  for s in r['items']:
2036
+ if s.get('type') == 'group':
2037
+ L.append(' - role: group')
2038
+ L.append(' gap: %d' % s['gap'])
2039
+ if s.get('css'):
2040
+ L.append(' css: "%s"'
2041
+ % str(s['css']).replace('"', "'"))
2042
+ L.append(' items:')
2043
+ for child in s['items']:
2044
+ role_id = text_role_ids.get(id(child))
2045
+ if role_id:
2046
+ L.append(' # text-role: %s' % role_id)
2047
+ if child.get('type') == 'decor':
2048
+ L.append(' - {role: container, css: "%s"}'
2049
+ % str(child.get('css') or '').replace('"', "'"))
2050
+ continue
2051
+ extra = ''
2052
+ if child.get('css') is not None:
2053
+ extra += ', css: "%s"' % str(child['css']).replace('"', "'")
2054
+ if child.get('asset'):
2055
+ extra += ', asset: %s' % child['asset']
2056
+ L.append(' - {role: %s, type: %s%s}'
2057
+ % (child['role'], child['type'], extra))
2058
+ continue
2059
+ role_id = text_role_ids.get(id(s))
2060
+ if role_id:
2061
+ L.append(' # text-role: %s' % role_id)
1762
2062
  # free 区带按坐标摆,而 slots 会被删掉,所以坐标必须写在这里
1763
2063
  bx = ', box: %s' % s['box'] if r['kind'] == 'free' else ''
1764
2064
  if s.get('type') == 'decor':
@@ -1766,25 +2066,22 @@ def emit_layouts(archetypes, ldir, busy_hints=None, facts=None, recipes=None):
1766
2066
  % (bx, (s.get('css') or '').replace('"', "'")))
1767
2067
  continue
1768
2068
  extra = bx
1769
- for k in ('size', 'weight', 'color', 'css', 'align', 'valign'):
1770
- if s.get(k) is not None:
1771
- # 色值与 css 串一律加引号:里面的逗号/冒号在 flow map 里是分隔符
1772
- extra += ', %s: %s' % (
1773
- k, '"%s"' % str(s[k]).replace('"', "'")
1774
- if k in ('color', 'css') else s[k])
2069
+ if s.get('css') is not None:
2070
+ # CSS 串一律加引号:里面的逗号/冒号在 flow map 里是分隔符
2071
+ extra += ', css: "%s"' % str(s['css']).replace('"', "'")
1775
2072
  if s.get('asset'):
1776
2073
  extra += ', asset: %s' % s['asset']
1777
2074
  L.append(' - {role: %s, type: %s%s}' % (s['role'], s['type'], extra))
1778
2075
  L.append(' slots:')
1779
2076
  for s in a['slots']:
2077
+ role_id = text_role_ids.get(id(s))
2078
+ if role_id:
2079
+ L.append(' # text-role: %s' % role_id)
1780
2080
  extra = ''
1781
2081
  if s.get('asset'):
1782
2082
  extra += ', asset: %s' % s['asset']
1783
- for k in ('size', 'weight', 'color', 'css', 'align', 'valign'):
1784
- if s.get(k) is not None:
1785
- v = s[k]
1786
- extra += ', %s: %s' % (
1787
- k, '"%s"' % str(v).replace('"', "'") if k in ('color', 'css') else v)
2083
+ if s.get('css') is not None:
2084
+ extra += ', css: "%s"' % str(s['css']).replace('"', "'")
1788
2085
  L.append(' - {role: %s, box: %s, type: %s%s}'
1789
2086
  % (s['role'], s['box'], s['type'], extra))
1790
2087
  if a.get('decor'):
@@ -1811,7 +2108,7 @@ def emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, l
1811
2108
 
1812
2109
  L = ['## Overview', '',
1813
2110
  'TODO: 两三句话讲清这套模板的性格与适用场景——看过联系表和页面重建图之后再写。', '']
1814
- L.append(('模板自带 %d 种版式,页型、坐标、字号、色值都直读自版式层。'
2111
+ L.append(('模板自带 %d 种版式,页型、坐标和 CSS 样式都直读自版式层。'
1815
2112
  % len(archetypes)) if (d.get('form_hint') or {}).get('form') == 3 else
1816
2113
  ('%d 页样张归纳出 %d 种页型。' % (d['counts']['slides'], len(archetypes))))
1817
2114
  L += ['', '## Usage', '',
@@ -1826,27 +2123,43 @@ def emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, l
1826
2123
  % sidecar,
1827
2124
  '3. **按页型给的形态落元素** —— 页型给 `flow` 就用流式,给 `slots` 就用绝对,'
1828
2125
  '两者只会出现一个。'
1829
- '**flow**:整块用一个纵向 flex 容器,`top` 是它的起始 y,`margin` 是左右边距,'
2126
+ '**flow**:整块用一个纵向 flex 容器,`top` 是它的起始 y,`margin` 是整块的左右边距,'
1830
2127
  '`gap` 是区带之间的间距;`regions` 从上往下依次排,**每个区带的高度由它自己的'
1831
2128
  '内容决定,不要写死高度**——上面的区带内容变多时,下面的自然被推下去,这正是'
1832
2129
  '这套表达要解决的事。区带内部:`kind: grid` 用 `grid-template-columns: repeat(cols, 1fr)` '
1833
2130
  '配 `gap: [行间距, 列间距]`;`kind: stack` 用纵向 flex 配 `gap`;`kind: free` '
1834
- '按该页型 `slots` 里的坐标绝对定位。`role: container` 的项是容器,把它的 `css` '
1835
- '原样写进 style,内容放进去。',
2131
+ ' item 自带的 `box` 绝对定位。区带自带 `margin: [左, 右]` 时用它的、'
2132
+ '覆盖整块的 `margin`(模板里居中的卡片组和贴左的标题横向范围本就不同);'
2133
+ '没带就用整块的 `margin`。`grid` 在自己这份左右边距里再 `repeat(cols, 1fr)`。'
2134
+ '`grid` 里的 `role: group` 是一张卡片:'
2135
+ 'group 的 `css` 用于外层容器,内部 `items` 按顺序纵向排布并使用 group 的 `gap`。'
2136
+ '每个 `role: container` 的项是容器,把它的 `css` 逐项原样写进 style,内容放进去;'
2137
+ '其中没有 `border-radius` 就按 `0`,不得自行补圆角。',
1836
2138
  '4. **按 slot 落元素(页型给的是 slots 时)** —— 每个 slot 渲染成一个绝对定位元素:`box` 是 '
1837
- '`[x, y, w, h]`(%dx%d 画布上的绝对像素),字号取 slot 的 `size`,'
1838
- '字重取 `weight`,颜色取 `color`,对齐取 `align` / `valign`。'
2139
+ '`[x, y, w, h]`(%dx%d 画布上的绝对像素),机械展开成 `left/top/width/height`;'
2140
+ 'slot `css` 是模板排版属性已转译好的声明串,原样写进 style,不要另选字号、'
2141
+ '内边距、颜色或对齐。'
1839
2142
  '带 `asset` 的 slot 是图片元素(logo、角标),把该资产放在它自己的 `box` 里;'
1840
2143
  '这个页型没有 `asset` 槽,这一页就不出现该资产。' % (canvas[0], canvas[1]),
1841
2144
  '5. **铺装饰几何** —— 页型的 `decor` 是这一页的图形骨架(图标托底的圆、'
1842
- '卡片、分隔线):每条渲染成一个绝对定位空元素,`box` 给位置,`css` 原样写进 style,'
1843
- '`geom: ellipse` 另加 `border-radius: 50%`。它们压在背景之上、slot 之下,'
2145
+ '卡片、分隔线):每条渲染成一个绝对定位空元素,`box` 给位置,`css` 逐项原样写进 '
2146
+ 'style;没有 `border-radius` 就按 `0`。只有 `geom: ellipse` 另加 '
2147
+ '`border-radius: 50%`。它们压在背景之上、slot 之下,'
1844
2148
  '落在 slot 上的图标正是靠它们托住。',
1845
- '6. **配色与字体** —— 色板见下面 Colors 段,字体栈与 `@import` 见 Typography 段。']
2149
+ '6. **落实全局设计** —— `design.md` frontmatter `colors`、`typography`、'
2150
+ '`spacing`、`rounded`、`components` 是全局 token;用 CSS variables、类名或内联'
2151
+ '样式承载。局部 slot / decor 的 `css` 优先,不能再解释成另一套视觉系统。'
2152
+ '字体使用 Typography 的完整栈与降级,不在运行时安装字体或依赖。',
2153
+ '7. **保持标题结构** —— 有合适页型可参考时,沿用该页型已有的标题层级与局部 '
2154
+ '`css`;只渲染该页型已有的文字槽,背景中已经可见的固定标题不再创建文本,'
2155
+ '页型没有 `subtitle` 槽就不新增副标题。没有合适参考时,按本包整体视觉组织标题。']
1846
2156
  if assets:
1847
2157
  L += ['', '资产文件(背景由页型的 `background` 字段指定,'
1848
2158
  '图片资产的位置由该页型 `slots` 里带 `asset` 的槽给出):', '',
1849
- '{{ASSET_TABLE}}']
2159
+ '{{ASSET_TABLE}}', '',
2160
+ '将包内 `assets/` 复制到项目内相对目录,再引用复制后的路径;最终 HTML 不引用'
2161
+ '抽取工作目录或本机绝对路径。附件只提供 `assetRoot` / `assetPaths` 时,把'
2162
+ '`assetRoot` 当作不透明前缀,只拼接清单中声明的相对路径。']
1850
2163
  L += ['', '文字与容器的外接矩形落在该页型 `background` 对应的 `text_safe` 内,'
1851
2164
  '避开 `avoid` 列出的区域(两者都在 %s 的 `backgrounds` 段)。内容装不下时换页型或拆页。'
1852
2165
  % sidecar, '',
@@ -1861,7 +2174,8 @@ def emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, l
1861
2174
  ',源为商业/内部字体无 web 分发源,按气质降级到 %s' % f['stack'][1]
1862
2175
  if len(f['stack']) > 1 else ''))
1863
2176
  L += ['', '字号轴:' + '、'.join('%s %dpx' % (k, round(v['sz_px'])) for k, v in roles.items())
1864
- + '。slot 自带 `size` 时以 slot 为准;层级在轴上没有的,复用最接近的一档。', '',
2177
+ + '。slot 自带 `css` 时以其中的 `font-size` 为准;没有 slot CSS 的新增层级,'
2178
+ '复用轴上最接近的一档。', '',
1865
2179
  '字体加载(**HARD REQUIREMENT:下面这行 @import 原样写入全局样式首行,禁止替换为 '
1866
2180
  'fonts.googleapis.com 或其他域**):', '', '```', imp, '```', '',
1867
2181
  '镜像只保证 wght 400 一档,更粗的字重由浏览器合成,字重不能作为唯一区分手段;'
@@ -1884,7 +2198,15 @@ def emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, l
1884
2198
  % (logo['id'], '、'.join('`%s`' % x for x in with_logo) or '(无)'))
1885
2199
  L += ['- 坐标、字号、色值、资产位置以 %s 为准;本文件的 Colors / Typography 是可用值的清单。'
1886
2200
  % sidecar,
1887
- '- 色板里没有绿/红这类语义色时,正负用同族颜色的深浅或透明度区分——先看 Colors 段确认。',
2201
+ '- 强调色族以 Colors 和 %s 的 slot CSS 为主;必要时可以使用 Colors 之外的颜色,'
2202
+ '但不能形成与模板主色竞争的第二强调色。' % sidecar,
2203
+ '- 新增颜色应与模板整体的色相、明度和饱和度关系协调。允许新增中性色、低彩度辅助色'
2204
+ '或局部语义色表达正负、风险、警告、状态、图表序列,但保持辅助层级;'
2205
+ '只要新色通过高饱和、高对比、大面积或跨页重复获得主视觉权重,'
2206
+ '或被用于标题、关键数字、图表主序列、卡片底色或渐变,就属于新的强调色,改用模板'
2207
+ '强调色族的深浅、透明度,或改用线型、纹理、标签区分。',
2208
+ '- 交付前逐页检查:色板、字体、版式、背景、资产和本段规则均来自本风格包;'
2209
+ '页面无资源加载失败、内容溢出或画幅裁切。',
1888
2210
  '- 本包里的数值就是普查结果,照用即可,无需重新统计颜色、字体或版式。',
1889
2211
  '- 风格包以文本形式(zip 摘要等)到手时,直接用摘要里 design.md / layouts.md 的文本。',
1890
2212
  '', '## Exceptions', '']
@@ -1974,7 +2296,7 @@ def emit_brief(d, ctx, ldir):
1974
2296
  if leftover:
1975
2297
  L += ['', '未归入 archetype 的页:%s —— 都是单页孤例,需要就自己补一个 archetype。'
1976
2298
  % ', '.join(map(str, leftover))]
1977
- L += ['', '各 archetype 的 slot 原文(据此起中文页型名、改 role):', '']
2299
+ L += ['', '各 archetype 的 slot 原文(据此起中文页型名,并在 text_roles 判断文本角色):', '']
1978
2300
  for a in archetypes:
1979
2301
  L.append('- `%s`(第 %s 页,覆盖 %s)' % (a['name'], a['rep'], a['pages']))
1980
2302
  for s in a['slots']:
@@ -2124,7 +2446,7 @@ def main(argv=None):
2124
2446
  gaps.append('源字体 %s 不在 font-fallback 表里,字体栈只有原名,消费端很可能装不上;'
2125
2447
  '按气质挑一个有 web 分发源的近似体补进栈,不要照抄原名。' % f['names'][0])
2126
2448
  nosize = [(a['name'], s['box']) for a in archetypes for s in a['slots']
2127
- if not s.get('asset') and not s.get('size')]
2449
+ if not s.get('asset') and not s.get('_font_size')]
2128
2450
  if nosize:
2129
2451
  gaps.append('这些文字槽在源文件任何层级都没有字号声明(都不是占位符,是普通文本框,'
2130
2452
  '继承源是 presentation.xml 的 defaultTextStyle,本抽取按约定不解继承链):'