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

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.
@@ -519,8 +519,25 @@ def read_txbody(tx, ctx, kind='txBody'):
519
519
  ins[k] = ctx.units.px(bp.get(k))
520
520
  if ins:
521
521
  b['insets_px'] = ins
522
- if bp.find('a:normAutofit', NS) is not None:
522
+ na = bp.find('a:normAutofit', NS)
523
+ if na is not None:
523
524
  b['autofit'] = 'norm'
525
+ # normAutofit 用 fontScale / lnSpcReduction(单位 1/1000 %)把大字缩进小框——
526
+ # 章节大号数字就靠它让 160px 的字装进 144px 的框。只记 autofit 存在、丢掉
527
+ # fontScale,消费端就拿到未缩放字号 + 原始框高,字比框高,渐变裁切把溢出的
528
+ # 底部切成透明。缺省即 100%(无缩放)。
529
+ fs = na.get('fontScale')
530
+ if fs is not None:
531
+ try:
532
+ b['font_scale'] = round(int(fs) / 100000.0, 4)
533
+ except (TypeError, ValueError):
534
+ pass
535
+ lsr = na.get('lnSpcReduction')
536
+ if lsr is not None:
537
+ try:
538
+ b['ln_spc_reduction'] = round(int(lsr) / 100000.0, 4)
539
+ except (TypeError, ValueError):
540
+ pass
524
541
  elif bp.find('a:spAutoFit', NS) is not None:
525
542
  b['autofit'] = 'shape'
526
543
  if b:
@@ -235,11 +235,13 @@ def parse_item_list(lines):
235
235
 
236
236
  def read_manifest(path):
237
237
  blocks = split_top_blocks(open(path, encoding='utf-8').read())
238
- man, assets, raw = {}, [], {}
238
+ man, assets, asset_decisions, raw = {}, [], [], {}
239
239
  derived = []
240
240
  for key, inline, lines in blocks:
241
241
  if key == 'assets':
242
242
  assets = parse_item_list(lines)
243
+ elif key == 'asset_decisions':
244
+ asset_decisions = parse_item_list(lines)
243
245
  elif key == 'derived':
244
246
  derived = parse_item_list(lines)
245
247
  elif inline.startswith('['):
@@ -253,6 +255,7 @@ def read_manifest(path):
253
255
  # 不把作者的折行改成一条长行。
254
256
  raw[key] = (inline, lines)
255
257
  man['assets'] = assets
258
+ man['asset_decisions'] = asset_decisions
256
259
  man['derived'] = derived
257
260
  man['_raw'] = raw
258
261
  return man
@@ -291,6 +294,68 @@ def strip_kind_prefix(aid, kind):
291
294
  return aid
292
295
 
293
296
 
297
+ def apply_asset_decisions(manifest, bound_sources=None):
298
+ """把临时图片判断并入正式 assets;content 只用于删除候选,不进入消费产物。"""
299
+ allowed = {'content', 'texture', 'logo', 'icon', 'slogan'}
300
+ decisions = {}
301
+ for item in manifest.get('asset_decisions') or []:
302
+ source = unquote(item.get('source_media') or '')
303
+ decision = unquote(item.get('decision') or '')
304
+ if not source or decision not in allowed:
305
+ raise Fail('asset_decisions 必须逐项填写 source_media 和 decision;'
306
+ 'decision 只能是 %s' % '|'.join(sorted(allowed)))
307
+ decisions[source] = decision
308
+ if bound_sources is not None:
309
+ unbound = sorted(set(decisions) - set(bound_sources))
310
+ if unbound:
311
+ raise Fail('asset_decisions 中这些图片没有对应图片槽:%s'
312
+ % '、'.join(unbound))
313
+
314
+ assets = []
315
+ by_source = {}
316
+ serial = Counter()
317
+ used_ids = {unquote(item.get('id') or '')
318
+ for item in manifest.get('assets') or [] if item.get('id')}
319
+ for item in manifest.get('assets') or []:
320
+ source = unquote(item.get('source_media') or '')
321
+ if source and decisions.get(source) == 'content':
322
+ continue
323
+ row = dict(item)
324
+ decision = decisions.get(source)
325
+ old_kind = unquote(row.get('kind') or '')
326
+ if decision:
327
+ row['kind'] = decision
328
+ if decision != old_kind:
329
+ serial[decision] += 1
330
+ aid = '%s-%d' % (KIND_PREFIX[decision], serial[decision])
331
+ while aid in used_ids:
332
+ serial[decision] += 1
333
+ aid = '%s-%d' % (KIND_PREFIX[decision], serial[decision])
334
+ row['id'] = aid
335
+ for field in ('role', 'theme', 'on-bg', 'mark'):
336
+ row.pop(field, None)
337
+ used_ids.add(unquote(row.get('id') or ''))
338
+ assets.append(row)
339
+ if source:
340
+ by_source[source] = row
341
+
342
+ for source, decision in decisions.items():
343
+ if decision == 'content' or source in by_source:
344
+ continue
345
+ serial[decision] += 1
346
+ prefix = KIND_PREFIX[decision]
347
+ aid = '%s-%d' % (prefix, serial[decision])
348
+ while aid in used_ids:
349
+ serial[decision] += 1
350
+ aid = '%s-%d' % (prefix, serial[decision])
351
+ used_ids.add(aid)
352
+ row = {'id': aid, 'source_media': source, 'kind': decision}
353
+ assets.append(row)
354
+ by_source[source] = row
355
+ manifest['assets'] = assets
356
+ return decisions, {source: unquote(item['id']) for source, item in by_source.items()}
357
+
358
+
294
359
  def media_row_of(extract, source_media):
295
360
  for m in extract.get('media') or []:
296
361
  if m.get('out') and os.path.basename(m['out']) == source_media:
@@ -475,6 +540,16 @@ def expand_placeholders(body, manifest, consumer, audit, extract, layouts_text):
475
540
  f += '(原图 `%s`)' % e['full']
476
541
  rows.append('| `%s` | %s | %s |' % (aid, f, when))
477
542
  body = body.replace('{{ASSET_TABLE}}', '\n'.join(rows))
543
+ if '{{LOGO_RULES}}' in body:
544
+ rows = []
545
+ for aid, entry in consumer.items():
546
+ if entry.get('kind') != 'logo':
547
+ continue
548
+ owners = _archetypes_using(layouts_text, aid)
549
+ rows.append('- `%s` 只出现在这些页型上:%s;其余页型不放。位置取该页型 '
550
+ '`slots` 里 `role: logo` 那一项的 `box`,原样使用该文件、保持原比例。'
551
+ % (aid, '、'.join('`%s`' % owner for owner in owners) or '(无)'))
552
+ body = body.replace('{{LOGO_RULES}}', '\n'.join(rows))
478
553
  if '{{LAYOUT_LIST}}' in body:
479
554
  rows, sect, names = [], None, {}
480
555
  for line in (layouts_text or '').split('\n'):
@@ -903,6 +978,64 @@ def build_design(manifest, l_frontmatter, consumer, body, has_sidecar, canvas):
903
978
  return text
904
979
 
905
980
 
981
+ def layout_forms(lines):
982
+ """每个页型草案里实际给了哪几种形态(flow / slots)。"""
983
+ out, cur = {}, None
984
+ for line in lines:
985
+ m = re.match(r'^ ([\w-]+):\s*$', line)
986
+ if m:
987
+ cur = m.group(1)
988
+ out[cur] = set()
989
+ continue
990
+ m = re.match(r'^ (flow|slots):\s*$', line)
991
+ if m and cur:
992
+ out[cur].add(m.group(1))
993
+ return out
994
+
995
+
996
+ def layout_modes(lines):
997
+ """读取扁平 `layout_modes:` 判断区。"""
998
+ out = {}
999
+ for line in lines:
1000
+ m = re.match(r'^\s{2}([\w-]+):\s*([^\s#]+)', line)
1001
+ if m:
1002
+ out[m.group(1)] = m.group(2).strip()
1003
+ return out
1004
+
1005
+
1006
+ def text_role_boxes(lines):
1007
+ """从 slots 里的同名标记取回文本槽坐标,供 flow 固定锚点复用。"""
1008
+ out, pending = {}, None
1009
+ for line in lines:
1010
+ marker = re.match(r'^\s*#\s*text-role:\s*([\w-]+)\s*$', line)
1011
+ if marker:
1012
+ pending = marker.group(1)
1013
+ continue
1014
+ if pending:
1015
+ box = re.search(r'\bbox:\s*(\[[^\]]+\])', line)
1016
+ if box:
1017
+ out[pending] = box.group(1)
1018
+ pending = None
1019
+ return out
1020
+
1021
+
1022
+ def select_layout_forms(lines, modes):
1023
+ """按 `layout_modes` 只保留每个页型选中的 flow 或 slots。"""
1024
+ out, layout, drop = [], None, False
1025
+ for line in lines:
1026
+ layout_match = re.match(r'^ ([\w-]+):\s*$', line)
1027
+ form_match = re.match(r'^ (flow|slots):\s*$', line)
1028
+ if layout_match:
1029
+ layout, drop = layout_match.group(1), False
1030
+ elif form_match:
1031
+ drop = modes.get(layout) not in (None, form_match.group(1))
1032
+ elif drop and re.match(r'^ \S', line):
1033
+ drop = False
1034
+ if not drop:
1035
+ out.append(line)
1036
+ return out
1037
+
1038
+
906
1039
  def shrink_safe_area(lines):
907
1040
  """text_safe 与 avoid 相交时把安全区收掉重叠的那部分。
908
1041
 
@@ -955,13 +1088,46 @@ def shrink_safe_area(lines):
955
1088
  return lines, None
956
1089
 
957
1090
 
958
- def build_layouts_md(layouts_blocks, canvas):
1091
+ def resolve_asset_candidate_lines(lines, decisions, asset_ids):
1092
+ """把 source_media 临时字段转成资产引用;内容图保留通用 pic 槽。"""
1093
+ out = []
1094
+ content_slots = set()
1095
+ for line in lines:
1096
+ match = re.search(r'\bsource_media:\s*([^,}]+)', line)
1097
+ if not match:
1098
+ out.append(line)
1099
+ continue
1100
+ source = unquote(match.group(1).strip())
1101
+ decision = decisions.get(source)
1102
+ line = re.sub(r'\s*,?\s*source_media:\s*[^,}]+', '', line, count=1)
1103
+ if decision == 'content':
1104
+ line = re.sub(r'\s*,?\s*asset:\s*[^,}]+', '', line, count=1)
1105
+ line = re.sub(r'(\brole:\s*)[\w-]+', r'\g<1>pic', line, count=1)
1106
+ content_key = line.strip()
1107
+ if content_key in content_slots:
1108
+ continue
1109
+ content_slots.add(content_key)
1110
+ out.append(line)
1111
+ continue
1112
+ aid = asset_ids.get(source)
1113
+ if not aid:
1114
+ raise Fail('layouts.yaml 引用了未完成资产判断的 source_media: %s' % source)
1115
+ if re.search(r'\basset:\s*[^,}]+', line):
1116
+ line = re.sub(r'(\basset:\s*)[^,}]+', r'\g<1>%s' % aid, line, count=1)
1117
+ else:
1118
+ line = line.replace('}', ', asset: %s}' % aid, 1)
1119
+ line = re.sub(r'(\brole:\s*)[\w-]+', r'\g<1>%s' % decision, line, count=1)
1120
+ out.append(line)
1121
+ return out
1122
+
1123
+
1124
+ def build_layouts_md(layouts_blocks, canvas, asset_decisions=None, asset_ids=None):
959
1125
  blocks = {k: (inline, lines) for k, inline, lines in layouts_blocks}
960
1126
  if 'canvas' in blocks:
961
1127
  raise Fail('layouts.yaml 不要写 canvas —— 脚本从 extract.json 取')
962
1128
  if 'layouts' not in blocks:
963
1129
  raise Fail('layouts.yaml 缺顶层键 `layouts:`')
964
- # `names:` / `roles:` / `text_roles:` / `bg_rules:` 是给 L 层集中填判断的
1130
+ # `names:` / `roles:` / `text_roles:` / `layout_modes:` / `bg_rules:` 是给 L 层集中填判断的
965
1131
  # 扁平区——在这里并回各 archetype,本身不进产物。让 L 层只改扁平键值,别去动 layouts 里的
966
1132
  # slots/confidence 结构(嵌套结构手改极易破坏缩进,进而静默改变语义)。
967
1133
  names, roles = {}, {}
@@ -1002,12 +1168,33 @@ def build_layouts_md(layouts_blocks, canvas):
1002
1168
  raise Fail('%s: %s' % (bg, err))
1003
1169
  out.append(' %s:' % bg)
1004
1170
  out += lines
1171
+ modes = layout_modes(blocks.get('layout_modes', ('', []))[1])
1005
1172
  out.append('layouts:')
1006
1173
  pending_text_role = None
1007
1174
  used_text_roles = set()
1008
- for line in blocks['layouts'][1]:
1175
+ source_layout_lines = resolve_asset_candidate_lines(
1176
+ blocks['layouts'][1], asset_decisions or {}, asset_ids or {})
1177
+ role_boxes = text_role_boxes(source_layout_lines)
1178
+ layout_lines = select_layout_forms(source_layout_lines, modes)
1179
+ cur, fixed_items = None, []
1180
+
1181
+ def flush_fixed_items():
1182
+ if not fixed_items:
1183
+ return
1184
+ out.append(' - kind: free')
1185
+ out.append(' items:')
1186
+ out.extend(fixed_items)
1187
+ fixed_items.clear()
1188
+
1189
+ for line in layout_lines:
1190
+ m = re.match(r'^ ([\w-]+):\s*$', line)
1191
+ if m:
1192
+ flush_fixed_items()
1193
+ cur = m.group(1)
1194
+ elif fixed_items and re.match(r'^ \S', line):
1195
+ flush_fixed_items()
1009
1196
  # 判断单里的结构事实(栅格、间距序列、样张字数、命中配方)是给 L 层判断用的,
1010
- # 不进产物——消费端要的是结论,不是推导过程。
1197
+ # 不进产物——消费端要的是结论,不是推导过程。layout_mode 同理,它是判断的载体。
1011
1198
  if line.lstrip().startswith('#'):
1012
1199
  marker = re.match(r'^\s*#\s*text-role:\s*([\w-]+)\s*$', line)
1013
1200
  if marker:
@@ -1017,21 +1204,32 @@ def build_layouts_md(layouts_blocks, canvas):
1017
1204
  if pending_text_role not in text_roles:
1018
1205
  raise Fail('text_roles 缺少 %s 的判断' % pending_text_role)
1019
1206
  role = text_roles[pending_text_role]
1020
- slot_type = role if role in ('title', 'subtitle', 'footer') else 'body'
1207
+ slot_type = role if role in ('title', 'subtitle', 'header', 'footer') else 'body'
1021
1208
  line, role_n = re.subn(r'(\{\s*role:\s*)[\w-]+',
1022
1209
  r'\g<1>%s' % role, line, count=1)
1023
1210
  line, type_n = re.subn(r'(\btype:\s*)[\w-]+',
1024
1211
  r'\g<1>%s' % slot_type, line, count=1)
1025
1212
  if role_n != 1 or type_n != 1:
1026
1213
  raise Fail('text_roles.%s 没有命中一个文本槽' % pending_text_role)
1214
+ if (modes.get(cur) == 'flow' and role in ('header', 'footer')
1215
+ and re.match(r'^\s{12}-\s*\{', line) and 'box:' not in line):
1216
+ box = role_boxes.get(pending_text_role)
1217
+ if not box:
1218
+ raise Fail('text_roles.%s 识别为 %s,但 slots 中没有坐标'
1219
+ % (pending_text_role, role))
1220
+ line = re.sub(r',\s*type:', ', box: %s, type:' % box, line, count=1)
1221
+ fixed_items.append(line)
1222
+ used_text_roles.add(pending_text_role)
1223
+ pending_text_role = None
1224
+ continue
1027
1225
  used_text_roles.add(pending_text_role)
1028
1226
  pending_text_role = None
1029
1227
  out.append(line)
1030
- m = re.match(r'^ ([\w-]+):\s*$', line)
1031
1228
  if m and m.group(1) in names:
1032
1229
  out.append(' name: "%s"' % names.pop(m.group(1)))
1033
1230
  if m and m.group(1) in roles:
1034
1231
  out.append(' role: %s' % roles.pop(m.group(1)))
1232
+ flush_fixed_items()
1035
1233
  if names:
1036
1234
  raise Fail('names 里这些页型在 layouts 下找不到:%s' % ', '.join(sorted(names)))
1037
1235
  if roles:
@@ -1045,23 +1243,22 @@ def build_layouts_md(layouts_blocks, canvas):
1045
1243
  '\n'.join(out), re.M)]
1046
1244
  if missing_role:
1047
1245
  raise Fail('这些页型没有 role(在 layouts.yaml 的 roles 段填):%s' % ', '.join(missing_role))
1048
- # flow slots 二选一:两份都留下,消费端不知道该听哪份坐标——design.md 正文与
1049
- # layouts.md 打架就是这么来的,别重演。
1050
- both, cur, has = [], None, {}
1051
- for line in blocks['layouts'][1]:
1052
- m = re.match(r'^ ([\w-]+):\s*$', line)
1053
- if m:
1054
- if cur and has.get('flow') and has.get('slots'):
1055
- both.append(cur)
1056
- cur, has = m.group(1), {}
1057
- continue
1058
- m = re.match(r'^ (flow|slots):\s*$', line)
1059
- if m:
1060
- has[m.group(1)] = True
1061
- if cur and has.get('flow') and has.get('slots'):
1062
- both.append(cur)
1063
- if both:
1064
- raise Fail('这些页型同时留了 flow 和 slots,二选一删掉另一个:%s' % ', '.join(both))
1246
+ # `layout_modes.*: TODO` 由上面那道通用 TODO 扫描报(它连行内提示一起打出来),
1247
+ # 这里只管它管不到的两种:整行被删、以及填了 flow/slots 之外的值。
1248
+ forms = layout_forms(blocks['layouts'][1])
1249
+ bad = []
1250
+ for k, v in sorted(modes.items()):
1251
+ if len(forms.get(k) or ()) < 2:
1252
+ continue # 只有一份形态,无从选择
1253
+ if v not in ('flow', 'slots'):
1254
+ bad.append('%s layout_modes 判断是 %r' % (k, v))
1255
+ elif v not in forms[k]:
1256
+ bad.append('%s 选了 %s 但该页型没有这一份' % (k, v))
1257
+ for k, forms_for_layout in sorted(forms.items()):
1258
+ if len(forms_for_layout) >= 2 and k not in modes:
1259
+ bad.append('%s layout_modes 判断' % k)
1260
+ if bad:
1261
+ raise Fail('layout_modes 只能填 flow 或 slots,一个页型一个词:%s' % ';'.join(bad))
1065
1262
  declared = set(re.findall(r'^ background:\s*(\S+)\s*$',
1066
1263
  '\n'.join(blocks['layouts'][1]), re.M))
1067
1264
  stray = set(bg_rules) - declared
@@ -1144,12 +1341,24 @@ def main(argv=None):
1144
1341
  lay_path = os.path.join(lout, 'layouts.yaml')
1145
1342
  layouts_blocks = (split_top_blocks(open(lay_path, encoding='utf-8').read())
1146
1343
  if os.path.exists(lay_path) else None)
1344
+ bound_sources = set()
1345
+ if layouts_blocks is not None:
1346
+ for _, _, lines in layouts_blocks:
1347
+ for line in lines:
1348
+ match = re.search(r'\bsource_media:\s*([^,}]+)', line)
1349
+ if match:
1350
+ bound_sources.add(unquote(match.group(1).strip()))
1351
+ asset_decisions, asset_ids = apply_asset_decisions(
1352
+ manifest, bound_sources=bound_sources if manifest.get('asset_decisions') else None)
1147
1353
  canvas = extract['canvas']['px']
1354
+ final_layouts = (build_layouts_md(
1355
+ layouts_blocks, canvas, asset_decisions=asset_decisions, asset_ids=asset_ids)
1356
+ if layouts_blocks is not None else None)
1148
1357
 
1149
1358
  os.makedirs(pack, exist_ok=True)
1150
1359
  consumer, audit, copied = place_assets(manifest, extract, stage1, pack)
1151
1360
 
1152
- lay_text = open(lay_path, encoding='utf-8').read() if os.path.exists(lay_path) else ''
1361
+ lay_text = final_layouts or ''
1153
1362
  body = expand_placeholders(body, manifest, consumer, audit, extract, lay_text)
1154
1363
  design = build_design(manifest, l_fm, consumer, body,
1155
1364
  has_sidecar=layouts_blocks is not None, canvas=canvas)
@@ -1157,7 +1366,7 @@ def main(argv=None):
1157
1366
  f.write(design)
1158
1367
  if layouts_blocks is not None:
1159
1368
  with open(os.path.join(pack, 'layouts.md'), 'w', encoding='utf-8') as f:
1160
- f.write(build_layouts_md(layouts_blocks, canvas))
1369
+ f.write(final_layouts)
1161
1370
 
1162
1371
  # 审计记录写回**抽取工作目录**,不进交付包:包是消费产物,`ref/` 里的东西
1163
1372
  # 没有任何门禁读、消费模型也用不上,放进去只会让「别读我」和「几 MB 材料」同时下发。
@@ -0,0 +1,161 @@
1
+ #!/usr/bin/env python3
2
+ """Integration regression tests for model-decided image roles."""
3
+ import json
4
+ import os
5
+ import sys
6
+ import tempfile
7
+ import unittest
8
+
9
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
10
+
11
+ from check_v2 import Pack, RULES # noqa: E402
12
+ from package import main as package_main # noqa: E402
13
+
14
+
15
+ class AssetJudgmentPackageTest(unittest.TestCase):
16
+ def test_package_strips_judgment_fields_and_preserves_content_slot(self):
17
+ try:
18
+ from PIL import Image
19
+ except ImportError:
20
+ self.skipTest('Pillow unavailable')
21
+
22
+ with tempfile.TemporaryDirectory() as root:
23
+ stage1 = os.path.join(root, 'stage1')
24
+ lout = os.path.join(stage1, 'l-out')
25
+ pack = os.path.join(root, 'pack')
26
+ media = os.path.join(stage1, 'media-out')
27
+ ref = os.path.join(stage1, 'ref')
28
+ os.makedirs(lout)
29
+ os.makedirs(media)
30
+ os.makedirs(ref)
31
+
32
+ Image.new('RGBA', (40, 20), (20, 40, 60, 255)).save(
33
+ os.path.join(media, 'partner-logo.png'))
34
+ Image.new('RGBA', (24, 24), (60, 40, 20, 255)).save(
35
+ os.path.join(media, 'ornament.png'))
36
+
37
+ box_rows = [
38
+ ('ppt/media/image1.png', 'partner-logo.png', [100, 200, 400, 200]),
39
+ ('ppt/media/image2.png', 'ornament.png', [40, 40, 120, 120]),
40
+ ]
41
+ extract = {
42
+ 'source': {'filename': 'fixture.pptx'},
43
+ 'canvas': {
44
+ 'px': [1920, 1080],
45
+ 'source': {'cx': 12192000, 'cy': 6858000, 'unit': 'EMU'},
46
+ },
47
+ 'media': [
48
+ {
49
+ 'media': source,
50
+ 'out': 'media-out/' + output,
51
+ 'source_px': [40, 20] if output == 'partner-logo.png' else [24, 24],
52
+ }
53
+ for source, output, _ in box_rows
54
+ ],
55
+ 'images': [
56
+ {
57
+ 'media': source,
58
+ 'boxes': [{
59
+ 'count': 1,
60
+ 'box': {'x': box[0], 'y': box[1], 'w': box[2], 'h': box[3]},
61
+ 'parts': ['ppt/slides/slide1.xml'],
62
+ }],
63
+ }
64
+ for source, _, box in box_rows
65
+ ],
66
+ 'color_freq': [],
67
+ 'text_scale': [],
68
+ }
69
+ with open(os.path.join(stage1, 'extract.json'), 'w', encoding='utf-8') as stream:
70
+ json.dump(extract, stream)
71
+ with open(os.path.join(ref, 'shapes.json'), 'w', encoding='utf-8') as stream:
72
+ json.dump({
73
+ 'shapes': [
74
+ {
75
+ 'part': 'ppt/slides/slide1.xml',
76
+ 'kind': 'pic',
77
+ 'name': output,
78
+ 'box': {'x': box[0], 'y': box[1], 'w': box[2], 'h': box[3]},
79
+ }
80
+ for _, output, box in box_rows
81
+ ],
82
+ }, stream)
83
+
84
+ with open(os.path.join(lout, 'manifest.yaml'), 'w', encoding='utf-8') as stream:
85
+ stream.write(
86
+ 'version: alpha\n'
87
+ 'name: asset-judgment-fixture\n'
88
+ 'description: >\n'
89
+ ' A restrained fixture with explicit image-role judgments and traced layout slots.\n'
90
+ 'assets:\n'
91
+ ' - id: logo-primary\n'
92
+ ' source_media: partner-logo.png\n'
93
+ ' kind: logo\n'
94
+ ' on-bg: light\n'
95
+ 'asset_decisions:\n'
96
+ ' - source_media: partner-logo.png\n'
97
+ ' decision: content\n'
98
+ ' - source_media: ornament.png\n'
99
+ ' decision: texture\n'
100
+ )
101
+ with open(os.path.join(lout, 'frontmatter.yaml'), 'w', encoding='utf-8') as stream:
102
+ stream.write('')
103
+ with open(os.path.join(lout, 'layouts.yaml'), 'w', encoding='utf-8') as stream:
104
+ stream.write(
105
+ 'layouts:\n'
106
+ ' content:\n'
107
+ ' name: "内容页"\n'
108
+ ' role: content\n'
109
+ ' slots:\n'
110
+ ' - {role: logo, box: [100, 200, 400, 200], type: pic, '
111
+ 'asset: logo-primary, source_media: partner-logo.png}\n'
112
+ ' - {role: asset-candidate, box: [40, 40, 120, 120], type: pic, '
113
+ 'source_media: ornament.png}\n'
114
+ ' confidence: high\n'
115
+ )
116
+ with open(os.path.join(lout, 'body.md'), 'w', encoding='utf-8') as stream:
117
+ stream.write(
118
+ '## Usage\n\n'
119
+ 'Read `layouts.md` before composing a slide.\n\n'
120
+ '## Assets\n\n'
121
+ '{{ASSET_TABLE}}\n\n'
122
+ '## Hard Rules\n\n'
123
+ '{{LOGO_RULES}}\n'
124
+ )
125
+ check_v1 = os.path.join(root, 'check_v1.py')
126
+ with open(check_v1, 'w', encoding='utf-8') as stream:
127
+ stream.write('raise SystemExit(0)\n')
128
+
129
+ rc = package_main([stage1, lout, pack, '--check-v1', check_v1])
130
+
131
+ self.assertEqual(rc, 0)
132
+ with open(os.path.join(pack, 'design.md'), encoding='utf-8') as stream:
133
+ design = stream.read()
134
+ with open(os.path.join(pack, 'layouts.md'), encoding='utf-8') as stream:
135
+ layouts = stream.read()
136
+ self.assertNotIn('asset_decisions', design)
137
+ self.assertNotIn('source_media', design)
138
+ self.assertNotIn('source_media', layouts)
139
+ self.assertNotIn('{{LOGO_RULES}}', design)
140
+ self.assertNotIn('logo-primary', design)
141
+ self.assertNotIn('logo-primary', layouts)
142
+ self.assertIn(
143
+ '- {role: pic, box: [100, 200, 400, 200], type: pic}',
144
+ layouts,
145
+ )
146
+ self.assertIn('texture-1:', design)
147
+ self.assertIn('kind: texture', design)
148
+ self.assertIn(
149
+ '- {role: texture, box: [40, 40, 120, 120], type: pic, asset: texture-1}',
150
+ layouts,
151
+ )
152
+ gate = Pack(pack)
153
+ self.assertFalse([
154
+ result
155
+ for result in (rule(gate) for rule in RULES)
156
+ if result.fails
157
+ ])
158
+
159
+
160
+ if __name__ == '__main__':
161
+ unittest.main()
@@ -49,7 +49,9 @@ class ColorContractTest(unittest.TestCase):
49
49
 
50
50
  self.assertIn('允许新增中性色、低彩度辅助色或局部语义色', body)
51
51
  self.assertRegex(body, r'正负.*风险.*警告.*状态.*图表序列')
52
- self.assertIn('不得自行新增第二强调色', body)
52
+ self.assertIn('必要时可以使用 Colors 之外的颜色', body)
53
+ self.assertIn('不能形成与模板主色竞争的第二强调色', body)
54
+ self.assertIn('色相、明度和饱和度关系', body)
53
55
  self.assertIn('高饱和、高对比、大面积或跨页重复', body)
54
56
  self.assertIn('标题、关键数字、图表主序列、卡片底色或渐变', body)
55
57
 
@@ -56,6 +56,7 @@ class DesignConsumerContractTest(unittest.TestCase):
56
56
  self.assertIn('沿用该页型已有的标题层级与局部 `css`', body)
57
57
  self.assertIn('背景中已经可见的固定标题不再创建文本', body)
58
58
  self.assertIn('没有 `subtitle` 槽就不新增副标题', body)
59
+ self.assertIn('区带自带 `margin: [左, 右]` 时用它的', body)
59
60
 
60
61
 
61
62
  if __name__ == '__main__':