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

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
  # 交给读得到重建图的模型。脚本只做客观归并——同一张底图 + 文字块数量相近的页
@@ -1291,7 +1426,11 @@ def draft_layouts(d, outdir):
1291
1426
  '——包里的坐标是模板量出来的,自创网格等于放弃这套版式', name)
1292
1427
  for i, t in enumerate(ordered[:6]):
1293
1428
  b = t['box']
1294
- if t is title:
1429
+ if t.get('needs_role'):
1430
+ role = typ = 'body'
1431
+ elif t.get('direct_type') in ('title', 'subtitle', 'footer', 'slide-number'):
1432
+ role = typ = t['direct_type']
1433
+ elif t is title:
1295
1434
  role = typ = 'title'
1296
1435
  elif (title and i == 1
1297
1436
  # 副标题 = 紧跟在标题下方、与标题左对齐的那一块。三个量都相对标题
@@ -1307,6 +1446,12 @@ def draft_layouts(d, outdir):
1307
1446
  round(b.get('w', 0)), round(b.get('h', 0))],
1308
1447
  'type': typ, 'sz': t['sz'], 'txt': t['txt']}
1309
1448
  row.update(t.get('style') or {})
1449
+ if t.get('needs_role'):
1450
+ row.update({
1451
+ '_needs_role': True,
1452
+ '_source_layer': t.get('source_layer'),
1453
+ '_placeholder': t.get('placeholder'),
1454
+ })
1310
1455
  slots.append(row)
1311
1456
  # 代表页上的小图元素按位置去重后落 slots(同一 logo 在不同页型位置不同)
1312
1457
  seen_mark = set()
@@ -1320,7 +1465,15 @@ def draft_layouts(d, outdir):
1320
1465
  'media': mk['media'],
1321
1466
  'box': [round(b.get('x', 0)), round(b.get('y', 0)),
1322
1467
  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))
1468
+ taken = {tuple(s['box']) for s in slots}
1469
+ decor = []
1470
+ seen_decor = set()
1471
+ for source_part in (rep.get('layout'), rep['part']):
1472
+ for item in collect_decor(shapes, source_part, taken, (cW, cH)):
1473
+ key = (tuple(item['box']), item['geom'], item['css'])
1474
+ if key not in seen_decor:
1475
+ seen_decor.add(key)
1476
+ decor.append(item)
1324
1477
  archetypes.append({'name': name, 'bg': None, 'bg_raw': bg_raw, 'slots': slots,
1325
1478
  'decor': decor,
1326
1479
  'pages': sorted(p['no'] for p in ps), 'rep': rep['no'],
@@ -1470,10 +1623,11 @@ def emit_frontmatter(d, tokens, fonts, roles, anchors, gaps, ldir):
1470
1623
  L.append('spacing:')
1471
1624
  L.append(' page-padding: {top: %s, right: %s, bottom: %s, left: %s}'
1472
1625
  % (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'])
1626
+ # rounded.card 是全局 token,只能表达全档共同的一档圆角。多个非零档位或零/非零
1627
+ # 混用时,圆角属于 layouts.md 里的局部形状事实,压成一个值会把直角容器也圆角化。
1628
+ radii = d.get('radii_census') or []
1629
+ if len(radii) == 1 and radii[0]['px'] >= 1:
1630
+ top = radii[0]
1477
1631
  L.append('rounded:')
1478
1632
  L.append(' card: %dpx' % round(top['px']))
1479
1633
  if edges_full:
@@ -1601,8 +1755,7 @@ def structure_facts(archetypes, d, shapes):
1601
1755
  'fill': fill, 'line': line, 'fx': fx, 'shapes': set()})
1602
1756
  g['n'] += 1
1603
1757
  g['parts'].add(s.get('part'))
1604
- if s.get('radius_px'):
1605
- g['radii'].append(s['radius_px'])
1758
+ g['radii'].append(s.get('radius_px') or 0)
1606
1759
  g['shapes'].add(id(s))
1607
1760
  ranked = sorted(groups.values(), key=lambda g: -g['n'])
1608
1761
  recipe_id = {}
@@ -1645,7 +1798,8 @@ def structure_facts(archetypes, d, shapes):
1645
1798
  def emit_layouts(archetypes, ldir, busy_hints=None, facts=None, recipes=None):
1646
1799
  prefilled = sum(1 for a in archetypes if a.get('zh'))
1647
1800
  L = ['# 判断单草案 —— package.py 读它产出 layouts.md,deck 的版式坐标从 layouts.md 读。',
1648
- '# 只改 names / roles / bg_rules 三段(都是扁平键值,改完 package.py 自动并回各页型)。',
1801
+ '# 只改 names / roles / text_roles / bg_rules 四段(都是扁平键值,'
1802
+ '改完 package.py 自动并回各页型)。',
1649
1803
  '# 下面 layouts 段是普查数值,一个字都不要动——改它容易连带删掉 slots/confidence。']
1650
1804
  if prefilled:
1651
1805
  L.append('# names 已按模板自带的版式名填好 %d 条,读一遍确认表意即可,通常不用改。' % prefilled)
@@ -1674,6 +1828,27 @@ def emit_layouts(archetypes, ldir, busy_hints=None, facts=None, recipes=None):
1674
1828
  len([s for s in a['slots'] if not s.get('asset')]),
1675
1829
  '/'.join(str(x) for x in szs[:5]) or '未声明',
1676
1830
  a.get('pic_n') or 0, ';有满屏底图' if a.get('bg_raw') else ''))
1831
+ text_role_ids = {}
1832
+ for a in archetypes:
1833
+ index = 0
1834
+ for slot in a.get('slots') or []:
1835
+ if not slot.get('_needs_role'):
1836
+ continue
1837
+ index += 1
1838
+ text_role_ids[id(slot)] = '%s-text-%d' % (a['name'], index)
1839
+ if text_role_ids:
1840
+ L.append('text_roles: # 取值 title|subtitle|header|footer|body;只改角色,不删槽')
1841
+ for a in archetypes:
1842
+ for slot in a.get('slots') or []:
1843
+ role_id = text_role_ids.get(id(slot))
1844
+ if not role_id:
1845
+ continue
1846
+ L.append(' %s: TODO文本角色 # 来源 %s;占位符 %s;样例 %s;'
1847
+ 'box %s;字号 %s;css %s'
1848
+ % (role_id, slot.get('_source_layer') or '-',
1849
+ slot.get('_placeholder') or '-', q(slot.get('txt') or ''),
1850
+ slot.get('box'), round(slot.get('sz') or 0),
1851
+ q(slot.get('css') or '未声明')))
1677
1852
  # 禁放区是**背景图**的属性,不是页型的属性——按背景资产分组,页型再多也不涨
1678
1853
  bgs = []
1679
1854
  for a in archetypes:
@@ -1759,6 +1934,9 @@ def emit_layouts(archetypes, ldir, busy_hints=None, facts=None, recipes=None):
1759
1934
  L.append(' gap: %d' % r['gap'])
1760
1935
  L.append(' items:')
1761
1936
  for s in r['items']:
1937
+ role_id = text_role_ids.get(id(s))
1938
+ if role_id:
1939
+ L.append(' # text-role: %s' % role_id)
1762
1940
  # free 区带按坐标摆,而 slots 会被删掉,所以坐标必须写在这里
1763
1941
  bx = ', box: %s' % s['box'] if r['kind'] == 'free' else ''
1764
1942
  if s.get('type') == 'decor':
@@ -1766,25 +1944,22 @@ def emit_layouts(archetypes, ldir, busy_hints=None, facts=None, recipes=None):
1766
1944
  % (bx, (s.get('css') or '').replace('"', "'")))
1767
1945
  continue
1768
1946
  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])
1947
+ if s.get('css') is not None:
1948
+ # CSS 串一律加引号:里面的逗号/冒号在 flow map 里是分隔符
1949
+ extra += ', css: "%s"' % str(s['css']).replace('"', "'")
1775
1950
  if s.get('asset'):
1776
1951
  extra += ', asset: %s' % s['asset']
1777
1952
  L.append(' - {role: %s, type: %s%s}' % (s['role'], s['type'], extra))
1778
1953
  L.append(' slots:')
1779
1954
  for s in a['slots']:
1955
+ role_id = text_role_ids.get(id(s))
1956
+ if role_id:
1957
+ L.append(' # text-role: %s' % role_id)
1780
1958
  extra = ''
1781
1959
  if s.get('asset'):
1782
1960
  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)
1961
+ if s.get('css') is not None:
1962
+ extra += ', css: "%s"' % str(s['css']).replace('"', "'")
1788
1963
  L.append(' - {role: %s, box: %s, type: %s%s}'
1789
1964
  % (s['role'], s['box'], s['type'], extra))
1790
1965
  if a.get('decor'):
@@ -1811,7 +1986,7 @@ def emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, l
1811
1986
 
1812
1987
  L = ['## Overview', '',
1813
1988
  'TODO: 两三句话讲清这套模板的性格与适用场景——看过联系表和页面重建图之后再写。', '']
1814
- L.append(('模板自带 %d 种版式,页型、坐标、字号、色值都直读自版式层。'
1989
+ L.append(('模板自带 %d 种版式,页型、坐标和 CSS 样式都直读自版式层。'
1815
1990
  % len(archetypes)) if (d.get('form_hint') or {}).get('form') == 3 else
1816
1991
  ('%d 页样张归纳出 %d 种页型。' % (d['counts']['slides'], len(archetypes))))
1817
1992
  L += ['', '## Usage', '',
@@ -1831,22 +2006,34 @@ def emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, l
1831
2006
  '内容决定,不要写死高度**——上面的区带内容变多时,下面的自然被推下去,这正是'
1832
2007
  '这套表达要解决的事。区带内部:`kind: grid` 用 `grid-template-columns: repeat(cols, 1fr)` '
1833
2008
  '配 `gap: [行间距, 列间距]`;`kind: stack` 用纵向 flex 配 `gap`;`kind: free` '
1834
- '按该页型 `slots` 里的坐标绝对定位。`role: container` 的项是容器,把它的 `css` '
1835
- '原样写进 style,内容放进去。',
2009
+ '按该页型 `slots` 里的坐标绝对定位。每个 `role: container` 的项是容器,把它的 '
2010
+ '`css` 逐项原样写进 style,内容放进去;其中没有 `border-radius` 就按 `0`,'
2011
+ '不得自行补圆角。',
1836
2012
  '4. **按 slot 落元素(页型给的是 slots 时)** —— 每个 slot 渲染成一个绝对定位元素:`box` 是 '
1837
- '`[x, y, w, h]`(%dx%d 画布上的绝对像素),字号取 slot 的 `size`,'
1838
- '字重取 `weight`,颜色取 `color`,对齐取 `align` / `valign`。'
2013
+ '`[x, y, w, h]`(%dx%d 画布上的绝对像素),机械展开成 `left/top/width/height`;'
2014
+ 'slot `css` 是模板排版属性已转译好的声明串,原样写进 style,不要另选字号、'
2015
+ '内边距、颜色或对齐。'
1839
2016
  '带 `asset` 的 slot 是图片元素(logo、角标),把该资产放在它自己的 `box` 里;'
1840
2017
  '这个页型没有 `asset` 槽,这一页就不出现该资产。' % (canvas[0], canvas[1]),
1841
2018
  '5. **铺装饰几何** —— 页型的 `decor` 是这一页的图形骨架(图标托底的圆、'
1842
- '卡片、分隔线):每条渲染成一个绝对定位空元素,`box` 给位置,`css` 原样写进 style,'
1843
- '`geom: ellipse` 另加 `border-radius: 50%`。它们压在背景之上、slot 之下,'
2019
+ '卡片、分隔线):每条渲染成一个绝对定位空元素,`box` 给位置,`css` 逐项原样写进 '
2020
+ 'style;没有 `border-radius` 就按 `0`。只有 `geom: ellipse` 另加 '
2021
+ '`border-radius: 50%`。它们压在背景之上、slot 之下,'
1844
2022
  '落在 slot 上的图标正是靠它们托住。',
1845
- '6. **配色与字体** —— 色板见下面 Colors 段,字体栈与 `@import` 见 Typography 段。']
2023
+ '6. **落实全局设计** —— `design.md` frontmatter `colors`、`typography`、'
2024
+ '`spacing`、`rounded`、`components` 是全局 token;用 CSS variables、类名或内联'
2025
+ '样式承载。局部 slot / decor 的 `css` 优先,不能再解释成另一套视觉系统。'
2026
+ '字体使用 Typography 的完整栈与降级,不在运行时安装字体或依赖。',
2027
+ '7. **保持标题结构** —— 有合适页型可参考时,沿用该页型已有的标题层级与局部 '
2028
+ '`css`;只渲染该页型已有的文字槽,背景中已经可见的固定标题不再创建文本,'
2029
+ '页型没有 `subtitle` 槽就不新增副标题。没有合适参考时,按本包整体视觉组织标题。']
1846
2030
  if assets:
1847
2031
  L += ['', '资产文件(背景由页型的 `background` 字段指定,'
1848
2032
  '图片资产的位置由该页型 `slots` 里带 `asset` 的槽给出):', '',
1849
- '{{ASSET_TABLE}}']
2033
+ '{{ASSET_TABLE}}', '',
2034
+ '将包内 `assets/` 复制到项目内相对目录,再引用复制后的路径;最终 HTML 不引用'
2035
+ '抽取工作目录或本机绝对路径。附件只提供 `assetRoot` / `assetPaths` 时,把'
2036
+ '`assetRoot` 当作不透明前缀,只拼接清单中声明的相对路径。']
1850
2037
  L += ['', '文字与容器的外接矩形落在该页型 `background` 对应的 `text_safe` 内,'
1851
2038
  '避开 `avoid` 列出的区域(两者都在 %s 的 `backgrounds` 段)。内容装不下时换页型或拆页。'
1852
2039
  % sidecar, '',
@@ -1861,7 +2048,8 @@ def emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, l
1861
2048
  ',源为商业/内部字体无 web 分发源,按气质降级到 %s' % f['stack'][1]
1862
2049
  if len(f['stack']) > 1 else ''))
1863
2050
  L += ['', '字号轴:' + '、'.join('%s %dpx' % (k, round(v['sz_px'])) for k, v in roles.items())
1864
- + '。slot 自带 `size` 时以 slot 为准;层级在轴上没有的,复用最接近的一档。', '',
2051
+ + '。slot 自带 `css` 时以其中的 `font-size` 为准;没有 slot CSS 的新增层级,'
2052
+ '复用轴上最接近的一档。', '',
1865
2053
  '字体加载(**HARD REQUIREMENT:下面这行 @import 原样写入全局样式首行,禁止替换为 '
1866
2054
  'fonts.googleapis.com 或其他域**):', '', '```', imp, '```', '',
1867
2055
  '镜像只保证 wght 400 一档,更粗的字重由浏览器合成,字重不能作为唯一区分手段;'
@@ -1884,7 +2072,14 @@ def emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, l
1884
2072
  % (logo['id'], '、'.join('`%s`' % x for x in with_logo) or '(无)'))
1885
2073
  L += ['- 坐标、字号、色值、资产位置以 %s 为准;本文件的 Colors / Typography 是可用值的清单。'
1886
2074
  % sidecar,
1887
- '- 色板里没有绿/红这类语义色时,正负用同族颜色的深浅或透明度区分——先看 Colors 段确认。',
2075
+ '- 强调色族以 Colors 和 %s 的 slot CSS 为准,不得自行新增第二强调色。'
2076
+ % sidecar,
2077
+ '- 允许新增中性色、低彩度辅助色或局部语义色来表达正负、风险、警告、状态、图表序列,'
2078
+ '但必须保持辅助层级;只要新色通过高饱和、高对比、大面积或跨页重复获得主视觉权重,'
2079
+ '或被用于标题、关键数字、图表主序列、卡片底色或渐变,就属于新的强调色,改用模板'
2080
+ '强调色族的深浅、透明度,或改用线型、纹理、标签区分。',
2081
+ '- 交付前逐页检查:色板、字体、版式、背景、资产和本段规则均来自本风格包;'
2082
+ '页面无资源加载失败、内容溢出或画幅裁切。',
1888
2083
  '- 本包里的数值就是普查结果,照用即可,无需重新统计颜色、字体或版式。',
1889
2084
  '- 风格包以文本形式(zip 摘要等)到手时,直接用摘要里 design.md / layouts.md 的文本。',
1890
2085
  '', '## Exceptions', '']
@@ -1974,7 +2169,7 @@ def emit_brief(d, ctx, ldir):
1974
2169
  if leftover:
1975
2170
  L += ['', '未归入 archetype 的页:%s —— 都是单页孤例,需要就自己补一个 archetype。'
1976
2171
  % ', '.join(map(str, leftover))]
1977
- L += ['', '各 archetype 的 slot 原文(据此起中文页型名、改 role):', '']
2172
+ L += ['', '各 archetype 的 slot 原文(据此起中文页型名,并在 text_roles 判断文本角色):', '']
1978
2173
  for a in archetypes:
1979
2174
  L.append('- `%s`(第 %s 页,覆盖 %s)' % (a['name'], a['rep'], a['pages']))
1980
2175
  for s in a['slots']:
@@ -2124,7 +2319,7 @@ def main(argv=None):
2124
2319
  gaps.append('源字体 %s 不在 font-fallback 表里,字体栈只有原名,消费端很可能装不上;'
2125
2320
  '按气质挑一个有 web 分发源的近似体补进栈,不要照抄原名。' % f['names'][0])
2126
2321
  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')]
2322
+ if not s.get('asset') and not s.get('_font_size')]
2128
2323
  if nosize:
2129
2324
  gaps.append('这些文字槽在源文件任何层级都没有字号声明(都不是占位符,是普通文本框,'
2130
2325
  '继承源是 presentation.xml 的 defaultTextStyle,本抽取按约定不解继承链):'