@lark-apaas/coding-steering 0.1.32-beta.0 → 0.1.32-dev.3598eb3

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 (61) hide show
  1. package/package.json +6 -6
  2. package/steering/design-html/skills/charts/SKILL.md +4 -0
  3. package/steering/design-html/skills/pptx-style-extract/SKILL.md +71 -26
  4. package/steering/design-html/skills/pptx-style-extract/font-fallback.yaml +3 -3
  5. package/steering/design-html/skills/pptx-style-extract/scripts/census.py +26 -14
  6. package/steering/design-html/skills/pptx-style-extract/scripts/check_v2.py +153 -8
  7. package/steering/design-html/skills/pptx-style-extract/scripts/draft.py +2831 -293
  8. package/steering/design-html/skills/pptx-style-extract/scripts/extract.py +544 -22
  9. package/steering/design-html/skills/pptx-style-extract/scripts/ooxml.py +19 -2
  10. package/steering/design-html/skills/pptx-style-extract/scripts/package.py +991 -165
  11. package/steering/design-html/skills/pptx-style-extract/scripts/parts.py +22 -3
  12. package/steering/design-html/skills/pptx-style-extract/scripts/query.py +4 -9
  13. package/steering/design-html/skills/pptx-style-extract/scripts/render_pages.py +20 -12
  14. package/steering/design-html/skills/pptx-style-extract/scripts/test_asset_judgment_package.py +556 -0
  15. package/steering/design-html/skills/pptx-style-extract/scripts/test_background_composite.py +364 -0
  16. package/steering/design-html/skills/pptx-style-extract/scripts/test_color_contract.py +60 -0
  17. package/steering/design-html/skills/pptx-style-extract/scripts/test_design_consumer_contract.py +76 -0
  18. package/steering/design-html/skills/pptx-style-extract/scripts/test_flow_layout_contract.py +528 -0
  19. package/steering/design-html/skills/pptx-style-extract/scripts/test_layout_css.py +1814 -0
  20. package/steering/design-html/skills/pptx-style-extract/scripts/test_logo_scope.py +600 -0
  21. package/steering/design-html/skills/pptx-style-extract/scripts/test_rounded_contract.py +112 -0
  22. package/steering/design-html/skills/pptx-style-extract/scripts/test_text_role_contract.py +315 -0
  23. package/steering/design-html/skills/pptx-style-extract/scripts/verify_layout_assets.py +421 -0
  24. package/steering/design-html/skills/pptx-style-extract/scripts/verify_logo_scope.py +12 -0
  25. package/steering/design-html/skills/pptx-style-extract/v2-format-spec.md +27 -15
  26. package/steering/design-html/skills/preflight/scripts/probe.sh +0 -0
  27. package/steering/nestjs-react-fullstack/skills/app-init-feasibility-guide/SKILL.md +1 -0
  28. package/steering/nestjs-react-fullstack/skills/authn-guide/SKILL.md +6 -0
  29. package/steering/nestjs-react-fullstack/skills/authz-guide/SKILL.md +5 -5
  30. package/steering/nestjs-react-fullstack/skills/authz-guide/references/dynamic-permission-guide.md +1 -1
  31. package/steering/nestjs-react-fullstack/skills/client-builtins-file-storage-service/SKILL.md +37 -113
  32. package/steering/nestjs-react-fullstack/skills/client-builtins-user-service/SKILL.md +13 -2
  33. package/steering/nestjs-react-fullstack/skills/code-fix/SKILL.md +7 -7
  34. package/steering/nestjs-react-fullstack/skills/coding-guide/SKILL.md +149 -24
  35. package/steering/nestjs-react-fullstack/skills/connections-sdk/SKILL.md +202 -0
  36. package/steering/nestjs-react-fullstack/skills/nestjs-cache/SKILL.md +255 -0
  37. package/steering/nestjs-react-fullstack/skills/plugin-guide/SKILL.md +158 -543
  38. package/steering/nestjs-react-fullstack/skills/plugin-guide/references/plugin-coding-guide.md +15 -1
  39. package/steering/nestjs-react-fullstack/skills/plugin-guide/references/table.md +30 -14
  40. package/steering/nestjs-react-fullstack/skills/raw-sql-boundary-audit/SKILL.md +63 -0
  41. package/steering/nestjs-react-fullstack/skills/server-builtins-file-storage-service/SKILL.md +1 -1
  42. package/steering/nestjs-react-fullstack/skills_common/trigger-guide/SKILL.md +284 -12
  43. package/steering/nestjs-react-fullstack/skills_local/plugin-guide/SKILL.md +4 -0
  44. package/steering/vite-react/skills/plugin-guide/SKILL.md +3 -1
  45. package/steering/vite-react/skills/react-three-fiber/SKILL.md +4 -0
  46. package/steering/nestjs-react-fullstack/skills/client-add-aily-web-chat/SKILL.md +0 -139
  47. package/steering/nestjs-react-fullstack/skills/feishu/SKILL.md +0 -269
  48. package/steering/nestjs-react-fullstack/skills/feishu/references/approval.md +0 -214
  49. package/steering/nestjs-react-fullstack/skills/feishu/references/attendance.md +0 -163
  50. package/steering/nestjs-react-fullstack/skills/feishu/references/bitable.md +0 -311
  51. package/steering/nestjs-react-fullstack/skills/feishu/references/calendar.md +0 -190
  52. package/steering/nestjs-react-fullstack/skills/feishu/references/contacts.md +0 -160
  53. package/steering/nestjs-react-fullstack/skills/feishu/references/doc.md +0 -257
  54. package/steering/nestjs-react-fullstack/skills/feishu/references/drive.md +0 -104
  55. package/steering/nestjs-react-fullstack/skills/feishu/references/events.md +0 -199
  56. package/steering/nestjs-react-fullstack/skills/feishu/references/id-convert.md +0 -128
  57. package/steering/nestjs-react-fullstack/skills/feishu/references/messaging.md +0 -207
  58. package/steering/nestjs-react-fullstack/skills/feishu/references/oauth.md +0 -165
  59. package/steering/nestjs-react-fullstack/skills/feishu/references/perm.md +0 -91
  60. package/steering/nestjs-react-fullstack/skills/feishu/references/wiki.md +0 -165
  61. package/steering/nestjs-react-fullstack/skills_common/trigger-guide/references/trigger-lifecycle.md +0 -301
@@ -38,7 +38,7 @@ L 层判断单 schema(<l-out-dir> 四个文件,这段就是填写说明书
38
38
  assets: # 可选;无资产包可整段省略
39
39
  - id: bg-cover # 必填。命名规则 <kind 前缀>-<语义名>,
40
40
  # logo- / slogan- / bg- / texture- / icon-
41
- source_media: image-1-1.jpeg # media-out/ 里的**原图**文件名(不是压缩版)
41
+ source_media: <原图文件名> # media-out/ 里的**原图**文件名(不是压缩版)
42
42
  kind: background # logo|slogan|background|texture|icon
43
43
  role: cover # background/icon 用;封闭枚举见规范 §2
44
44
  theme: dark # 双主题包按需
@@ -52,6 +52,15 @@ L 层判断单 schema(<l-out-dir> 四个文件,这段就是填写说明书
52
52
  role: content
53
53
  color: "{colors.surface}"
54
54
 
55
+ asset_vision_groups: # 抽取草案专用,打包后不进入消费包
56
+ - id: vision-1
57
+ source_media: [mark.png, ornament.png]
58
+ visual_kind: decorative # FaaS 枚举;同组默认,可被 asset_decisions 覆盖
59
+
60
+ asset_decisions: # 可选:单图覆盖;旧版 decision 仍兼容
61
+ - source_media: chart.png
62
+ visual_kind: chart
63
+
55
64
  说明:`boxes` / `aspect` / `canvas-source` **不要填**——脚本按 source_media 从
56
65
  extract.json 的 images[] 直接取,写进 ref/audit.yaml。
57
66
 
@@ -80,9 +89,9 @@ L 层判断单 schema(<l-out-dir> 四个文件,这段就是填写说明书
80
89
 
81
90
  colors:
82
91
  surface: "#FFFFFF"
83
- primary: "#2D5A8E"
92
+ primary: "#RRGGBB"
84
93
  safe-area:
85
- content: {top: 60, right: 90, bottom: 113, left: 90, applies-to: [content]}
94
+ content: {top: <px>, right: <px>, bottom: <px>, left: <px>, applies-to: [content]}
86
95
  confidence: medium
87
96
 
88
97
  --------------------------------------------------------------------------------
@@ -98,7 +107,8 @@ L 层判断单 schema(<l-out-dir> 四个文件,这段就是填写说明书
98
107
  role: cover
99
108
  background: bg-cover
100
109
  slots:
101
- - {role: title, box: [257, 313, 1406, 130], type: title}
110
+ - {role: title, box: [<x>, <y>, <w>, <h>], type: title,
111
+ css: "<由源模板转译出的 CSS 声明串>"}
102
112
  confidence: high
103
113
 
104
114
  可选 `body:` 块标量 —— 追加到 layouts.md frontmatter 之后作为说明正文。
@@ -111,7 +121,7 @@ L 层判断单 schema(<l-out-dir> 四个文件,这段就是填写说明书
111
121
  Exceptions……)。脚本原样拼在 frontmatter 之后,不改一个字。
112
122
  """
113
123
  import argparse
114
- from collections import Counter
124
+ from collections import Counter, defaultdict
115
125
  import hashlib
116
126
  import json
117
127
  import os
@@ -139,9 +149,37 @@ ASSET_CONSUMER_FIELDS = ['path', 'url', 'color', 'full', 'kind', 'role',
139
149
  ASSET_AUDIT_FIELDS = ['boxes', 'aspect', 'mark', 'confidence']
140
150
  KIND_PREFIX = {'background': 'bg', 'logo': 'logo', 'slogan': 'slogan',
141
151
  'texture': 'texture', 'icon': 'icon'}
142
- # stage1 ref/ 里随包分发的审计件(其余如 shapes.json 体量大、不进包)
143
- REF_CARRY = ('color-freq-raw.json', 'font-clusters.json', 's5-acceptance.json',
144
- 'content-clusters.json', 'notes.md')
152
+ # studio_server_faas assetVisualKinds 对齐。它是抽取期视觉分类,不是 v2
153
+ # 消费包的 kind:后者仍只允许 KIND_PREFIX 中的五种资产类型。
154
+ ASSET_VISUAL_KINDS = {
155
+ 'logo', 'slogan', 'background', 'texture', 'icon',
156
+ 'decorative', 'illustration', 'photo', 'chart', 'screenshot',
157
+ 'footer-copyright', 'page-number', 'watermark', 'content-image', 'unknown',
158
+ }
159
+ VISUAL_KIND_TO_DECISION = {
160
+ 'logo': 'logo',
161
+ 'slogan': 'slogan',
162
+ 'background': 'background',
163
+ 'texture': 'texture',
164
+ 'icon': 'icon',
165
+ 'decorative': 'texture',
166
+ 'illustration': 'texture',
167
+ 'photo': 'content',
168
+ 'chart': 'content',
169
+ 'screenshot': 'content',
170
+ 'content-image': 'content',
171
+ 'footer-copyright': 'omit',
172
+ 'page-number': 'omit',
173
+ 'watermark': 'omit',
174
+ # FaaS 也会把非内容的 unknown 局部图保留为 texture,避免丢掉未能命名的装饰。
175
+ 'unknown': 'texture',
176
+ }
177
+ LEGACY_ASSET_DECISIONS = {'content', 'texture', 'logo', 'icon', 'slogan'}
178
+ # 交付包的 ref/ 只留 audit.yaml(人复核数值出处用,几 KB)。
179
+ # 频次原表、聚类原始数据、extract.json、重建图、logo 候选图全部留在 stage1 抽取目录,
180
+ # 不拷进包——规范原本指望「下发时链路剥离」,但链路上没有环节真的剥离,结果
181
+ # 审计材料带着「别读我」一起进消费上下文,还占掉包体的大头。
182
+ REF_CARRY = ()
145
183
 
146
184
 
147
185
  class Fail(SystemExit):
@@ -149,6 +187,46 @@ class Fail(SystemExit):
149
187
  super().__init__('package.py: %s' % msg)
150
188
 
151
189
 
190
+ class AssetDecisions(dict):
191
+ """按素材的默认决定,以及只在对应 slot 生效的实例覆盖。"""
192
+
193
+ def __init__(self, defaults=None, overrides=None, roles=None,
194
+ default_overrides=None, explicit_overrides=None):
195
+ super().__init__(defaults or {})
196
+ self.overrides = overrides or {}
197
+ self.roles = roles or {}
198
+ self.default_overrides = default_overrides or set()
199
+ self.explicit_overrides = explicit_overrides or set()
200
+
201
+ def for_slot(self, source, line, layout_name=None):
202
+ box = source_box(line) or slot_box(line)
203
+ return self.for_scope(source, layout_name, box)
204
+
205
+ def for_scope(self, source, layout_name, box):
206
+ if box is not None:
207
+ for key in ((source, layout_name, box), (source, None, box)):
208
+ if key in self.explicit_overrides:
209
+ return self.overrides[key]
210
+ if source in self.default_overrides:
211
+ return self.get(source)
212
+ if box is not None:
213
+ for key in ((source, layout_name, box), (source, None, box)):
214
+ if key in self.overrides:
215
+ return self.overrides[key]
216
+ return self.get(source)
217
+
218
+
219
+ class AssetIds(dict):
220
+ """同一原图因实例用途不同而落成多个资产时,按最终用途取对应 id。"""
221
+
222
+ def __init__(self, defaults=None, by_decision=None):
223
+ super().__init__(defaults or {})
224
+ self.by_decision = by_decision or {}
225
+
226
+ def for_decision(self, source, decision):
227
+ return self.by_decision.get((source, decision)) or self.get(source)
228
+
229
+
152
230
  # ------------------------------------------------------------ 极简 YAML 读取
153
231
  def split_top_blocks(text):
154
232
  """顶层 `key:` 切块。返回 [(key, inline_value, block_lines)],块内逐字节保留。"""
@@ -173,6 +251,55 @@ def split_top_blocks(text):
173
251
  return out
174
252
 
175
253
 
254
+ LAYOUT_CONTROL_KEYS = {
255
+ 'names', 'roles', 'text_roles', 'layout_modes', 'bg_rules',
256
+ }
257
+
258
+
259
+ def control_entry_keys(lines):
260
+ """读取扁平判断段的一级条目,防止整段覆盖时漏掉旧结论。"""
261
+ return {
262
+ match.group(1)
263
+ for line in lines
264
+ for match in [re.match(r'^\s{2}([\w-]+):', line)]
265
+ if match
266
+ }
267
+
268
+
269
+ def load_layout_blocks(lout):
270
+ """读取版式坐标事实;新草案把可编辑判断区放在独立的小文件里。"""
271
+ lay_path = os.path.join(lout, 'layouts.yaml')
272
+ if not os.path.exists(lay_path):
273
+ return None
274
+ with open(lay_path, encoding='utf-8') as stream:
275
+ layouts = split_top_blocks(stream.read())
276
+ controls_path = os.path.join(lout, 'layout-controls.yaml')
277
+ if not os.path.exists(controls_path):
278
+ return layouts
279
+ with open(controls_path, encoding='utf-8') as stream:
280
+ controls = split_top_blocks(stream.read())
281
+ invalid = {key for key, _, _ in controls} - LAYOUT_CONTROL_KEYS
282
+ if invalid:
283
+ raise Fail('layout-controls.yaml 只能包含 %s,不能包含:%s'
284
+ % ('|'.join(sorted(LAYOUT_CONTROL_KEYS)), ', '.join(sorted(invalid))))
285
+ legacy_keys = {key for key, _, _ in layouts if key in LAYOUT_CONTROL_KEYS}
286
+ control_keys = {key for key, _, _ in controls}
287
+ missing = legacy_keys - control_keys
288
+ if missing:
289
+ raise Fail('layout-controls.yaml 必须覆盖 layouts.yaml 的全部判断段,缺:%s'
290
+ % ', '.join(sorted(missing)))
291
+ legacy_blocks = {key: lines for key, _, lines in layouts}
292
+ control_blocks = {key: lines for key, _, lines in controls}
293
+ for key in sorted(legacy_keys):
294
+ missing_entries = (control_entry_keys(legacy_blocks[key])
295
+ - control_entry_keys(control_blocks[key]))
296
+ if missing_entries:
297
+ raise Fail('layout-controls.yaml 的 %s 段缺少旧判断条目:%s'
298
+ % (key, ', '.join(sorted(missing_entries))))
299
+ base = [block for block in layouts if block[0] not in LAYOUT_CONTROL_KEYS]
300
+ return controls + base
301
+
302
+
176
303
  def unquote(s):
177
304
  s = s.strip()
178
305
  if len(s) >= 2 and s[0] == s[-1] and s[0] in '"\'':
@@ -232,11 +359,15 @@ def parse_item_list(lines):
232
359
 
233
360
  def read_manifest(path):
234
361
  blocks = split_top_blocks(open(path, encoding='utf-8').read())
235
- man, assets, raw = {}, [], {}
362
+ man, assets, asset_vision_groups, asset_decisions, raw = {}, [], [], [], {}
236
363
  derived = []
237
364
  for key, inline, lines in blocks:
238
365
  if key == 'assets':
239
366
  assets = parse_item_list(lines)
367
+ elif key == 'asset_vision_groups':
368
+ asset_vision_groups = parse_item_list(lines)
369
+ elif key == 'asset_decisions':
370
+ asset_decisions = parse_item_list(lines)
240
371
  elif key == 'derived':
241
372
  derived = parse_item_list(lines)
242
373
  elif inline.startswith('['):
@@ -250,11 +381,102 @@ def read_manifest(path):
250
381
  # 不把作者的折行改成一条长行。
251
382
  raw[key] = (inline, lines)
252
383
  man['assets'] = assets
384
+ man['asset_vision_groups'] = asset_vision_groups
385
+ man['asset_decisions'] = asset_decisions
253
386
  man['derived'] = derived
254
387
  man['_raw'] = raw
255
388
  return man
256
389
 
257
390
 
391
+ def validate_asset_vision_groups(lout, manifest):
392
+ """新草案的候选索引存在时,视觉判断必须逐项覆盖且不改写实例事实。"""
393
+ index_path = os.path.join(lout, 'asset-vision-groups.json')
394
+ if not os.path.exists(index_path):
395
+ return {}
396
+ try:
397
+ with open(index_path, encoding='utf-8') as stream:
398
+ index = json.load(stream)
399
+ except (OSError, ValueError) as exc:
400
+ raise Fail('asset-vision-groups.json 无法读取:%s' % exc)
401
+ if 'selected' not in index:
402
+ raise Fail('asset-vision-groups.json 缺少 selected 候选列表')
403
+
404
+ expected = {}
405
+ for group in index.get('selected') or []:
406
+ for candidate in group.get('candidates') or []:
407
+ candidate_id = unquote(str(candidate.get('id') or ''))
408
+ source = unquote(str(candidate.get('source_media') or candidate.get('file') or ''))
409
+ placements = candidate.get('placements') or []
410
+ placement = placements[0] if placements else {}
411
+ raw_box = placement.get('box')
412
+ box = tuple(raw_box) if isinstance(raw_box, list) and len(raw_box) == 4 else None
413
+ layout = unquote(str(candidate.get('layout') or placement.get('archetype') or ''))
414
+ if not candidate_id or not source or box is None:
415
+ raise Fail('asset-vision-groups.json 的候选缺少 id、source_media 或 box')
416
+ if candidate_id in expected:
417
+ raise Fail('asset-vision-groups.json 的候选 id 重复:%s' % candidate_id)
418
+ expected[candidate_id] = (source, layout or None, box)
419
+
420
+ actual = {}
421
+ for item in manifest.get('asset_vision_groups') or []:
422
+ candidate_id = unquote(item.get('id') or '')
423
+ sources = source_media_list(item.get('source_media') or '')
424
+ layout, box = decision_scope(item, 'asset_vision_groups.%s' % candidate_id)
425
+ if not candidate_id:
426
+ raise Fail('asset_vision_groups 必须逐项填写 id')
427
+ if candidate_id in actual:
428
+ raise Fail('asset_vision_groups 的候选 id 重复:%s' % candidate_id)
429
+ actual[candidate_id] = (sources, layout, box)
430
+
431
+ expected_ids = set(expected)
432
+ actual_ids = set(actual)
433
+ instance_format = index.get('version', 1) >= 2
434
+ if instance_format:
435
+ missing = sorted(expected_ids - actual_ids)
436
+ extra = sorted(actual_ids - expected_ids)
437
+ if missing or extra:
438
+ problems = []
439
+ if missing:
440
+ problems.append('缺少:%s' % ', '.join(missing))
441
+ if extra:
442
+ problems.append('出现未知项:%s' % ', '.join(extra))
443
+ raise Fail('asset_vision_groups 必须逐项保留抽取候选,%s'
444
+ % ';'.join(problems))
445
+ for candidate_id, expected_scope in expected.items():
446
+ sources, layout, box = actual[candidate_id]
447
+ expected_source, expected_layout, expected_box = expected_scope
448
+ if sources != [expected_source] or box != expected_box:
449
+ raise Fail('asset_vision_groups.%s 改写了抽取候选的图片或位置'
450
+ % candidate_id)
451
+ if layout and expected_layout and layout != expected_layout:
452
+ raise Fail('asset_vision_groups.%s 改写了抽取候选的页型'
453
+ % candidate_id)
454
+ return expected
455
+
456
+ # 旧草案用视觉组 id 和 source_media 列表表达默认判断,无法逐实例比对。
457
+ # 保留这种兼容输入,但仍要求每个选中的候选来源至少被一个组覆盖。
458
+ actual_sources = {
459
+ source for sources, _, _ in actual.values() for source in sources
460
+ }
461
+ missing_sources = sorted({
462
+ source for source, _, _ in expected.values()
463
+ } - actual_sources)
464
+ if missing_sources:
465
+ raise Fail('asset_vision_groups 必须覆盖抽取候选来源,缺少:%s'
466
+ % ', '.join(missing_sources))
467
+ return {}
468
+
469
+
470
+ def bind_asset_vision_group_scopes(lout, manifest):
471
+ """用 v2 候选 id 恢复实例页型,模型无需重复填写 layout。"""
472
+ expected = validate_asset_vision_groups(lout, manifest)
473
+ for item in manifest.get('asset_vision_groups') or []:
474
+ candidate_id = unquote(item.get('id') or '')
475
+ scope = expected.get(candidate_id)
476
+ if scope and scope[1]:
477
+ item['_resolved_layout'] = scope[1]
478
+
479
+
258
480
  # ------------------------------------------------------------------- 组装
259
481
  def yaml_scalar(v):
260
482
  """标量回写。已带引号 / 流式结构原样;含 YAML 危险序列才补引号。"""
@@ -272,6 +494,10 @@ def truthy(v):
272
494
  return str(v).strip().lower() in ('1', 'true', 'yes', 'on')
273
495
 
274
496
 
497
+ # 浏览器能解码的图片格式。落进包的资产必须在此列,否则消费端引用到就是一张空白图。
498
+ WEB_SAFE_EXT = {'png', 'jpg', 'jpeg', 'webp', 'gif', 'svg', 'avif'}
499
+
500
+
275
501
  def asset_ext(name):
276
502
  return name.rsplit('.', 1)[-1].lower() if '.' in name else ''
277
503
 
@@ -284,6 +510,284 @@ def strip_kind_prefix(aid, kind):
284
510
  return aid
285
511
 
286
512
 
513
+ def source_media_list(value):
514
+ """`source_media: one.png` 与视觉组的 `[a.png, b.png]` 两种写法都接受。"""
515
+ raw = value.strip()
516
+ parsed = parse_flow_list(raw)
517
+ return parsed if parsed is not None else [unquote(raw)] if raw else []
518
+
519
+
520
+ def visual_kind_decision(value, label):
521
+ visual_kind = unquote(value or '')
522
+ if visual_kind not in ASSET_VISUAL_KINDS:
523
+ raise Fail('%s 的 visual_kind 只能是 %s'
524
+ % (label, '|'.join(sorted(ASSET_VISUAL_KINDS))))
525
+ return VISUAL_KIND_TO_DECISION[visual_kind]
526
+
527
+
528
+ def slot_box(line):
529
+ match = re.search(r'\bbox:\s*\[([^\]]+)\]', line)
530
+ return parse_box_match(match)
531
+
532
+
533
+ def source_box(line):
534
+ match = re.search(r'\bsource_box:\s*\[([^\]]+)\]', line)
535
+ return parse_box_match(match)
536
+
537
+
538
+ def parse_box_match(match):
539
+ if not match:
540
+ return None
541
+ try:
542
+ values = tuple(int(float(part.strip())) for part in match.group(1).split(','))
543
+ except ValueError:
544
+ return None
545
+ return values if len(values) == 4 else None
546
+
547
+
548
+ def decision_scope(item, label):
549
+ raw_box = item.get('box')
550
+ box = slot_box('box: %s' % raw_box) if raw_box else None
551
+ if raw_box and box is None:
552
+ raise Fail('%s 的 box 必须是四个数的 [x, y, w, h]' % label)
553
+ layout = unquote(item.get('_resolved_layout') or item.get('layout') or '')
554
+ if layout and box is None:
555
+ raise Fail('%s 的 layout 必须与 box 一起填写' % label)
556
+ return layout or None, box
557
+
558
+
559
+ def register_asset_decision(decisions, visual_kinds, key, decision, visual_kind):
560
+ """合并相同实例的重复判断;显式 visual_kind 必须逐字一致。"""
561
+ if key not in decisions:
562
+ decisions[key] = decision
563
+ visual_kinds[key] = visual_kind
564
+ return
565
+ previous_kind = visual_kinds[key]
566
+ if ((previous_kind and visual_kind and previous_kind != visual_kind)
567
+ or (not previous_kind or not visual_kind)
568
+ and decisions[key] != decision):
569
+ label = 'source_media' if key[2] is None else '实例'
570
+ raise Fail('图片判断的 %s判断冲突: %s' % (label, key[0]))
571
+
572
+
573
+ def register_asset_role(roles, background_roles, source, layout, box, decision, role):
574
+ if not role:
575
+ return
576
+ if source in roles and roles[source] != role:
577
+ raise Fail('图片判断的 source_media role 冲突: %s' % source)
578
+ roles[source] = role
579
+ if decision != 'background' or box is None:
580
+ return
581
+ for (known_layout, known_box), (known_source, known_role) in background_roles.items():
582
+ if (known_box == box
583
+ and (known_layout == layout
584
+ or known_layout is None
585
+ or layout is None)
586
+ and known_role != role):
587
+ raise Fail('图片判断的背景实例 role 冲突: %s、%s'
588
+ % (known_source, source))
589
+ background_roles[(layout, box)] = (source, role)
590
+
591
+
592
+ def decision_overrides(manifest):
593
+ """把视觉组默认值与单图/单页型实例覆盖展开为按 source_media 的内部决定。"""
594
+ scoped, visual_kinds, roles, background_roles = {}, {}, {}, {}
595
+ for group in manifest.get('asset_vision_groups') or []:
596
+ group_id = unquote(group.get('id') or '')
597
+ sources = source_media_list(group.get('source_media') or '')
598
+ if not group_id or not sources:
599
+ raise Fail('asset_vision_groups 必须逐项填写 id 与 source_media')
600
+ visual_kind = unquote(group.get('visual_kind') or '')
601
+ decision = visual_kind_decision(visual_kind,
602
+ 'asset_vision_groups.%s' % group_id)
603
+ layout, box = decision_scope(
604
+ group, 'asset_vision_groups.%s' % group_id)
605
+ if box is not None and len(sources) != 1:
606
+ raise Fail('asset_vision_groups.%s 的实例判断只能填写一个 source_media'
607
+ % group_id)
608
+ role = unquote(group.get('role') or '')
609
+ for source in sources:
610
+ key = (source, layout, box)
611
+ register_asset_decision(scoped, visual_kinds, key, decision, visual_kind)
612
+ register_asset_role(
613
+ roles, background_roles, source, layout, box, decision, role)
614
+
615
+ explicit_defaults, explicit_default_kinds = {}, {}
616
+ explicit_overrides, explicit_override_kinds = {}, {}
617
+ explicit_roles = {}
618
+ for item in manifest.get('asset_decisions') or []:
619
+ source = unquote(item.get('source_media') or '')
620
+ visual_kind = item.get('visual_kind')
621
+ legacy = item.get('decision')
622
+ if not source or (visual_kind and legacy):
623
+ raise Fail('asset_decisions 每项只填 source_media 和 visual_kind(或旧 decision)')
624
+ if visual_kind:
625
+ decision = visual_kind_decision(visual_kind, 'asset_decisions.%s' % source)
626
+ else:
627
+ decision = unquote(legacy or '')
628
+ if decision not in LEGACY_ASSET_DECISIONS:
629
+ raise Fail('asset_decisions 的旧 decision 只能是 %s'
630
+ % '|'.join(sorted(LEGACY_ASSET_DECISIONS)))
631
+ layout, box = decision_scope(item, 'asset_decisions.%s' % source)
632
+ target = (explicit_defaults, explicit_default_kinds) if box is None else (
633
+ explicit_overrides, explicit_override_kinds)
634
+ register_asset_decision(
635
+ target[0], target[1], (source, layout, box), decision,
636
+ unquote(visual_kind) if visual_kind else None)
637
+ role = unquote(item.get('role') or '')
638
+ register_asset_role(
639
+ explicit_roles, background_roles, source, layout, box, decision, role)
640
+
641
+ scoped.update(explicit_defaults)
642
+ scoped.update(explicit_overrides)
643
+ for source, role in explicit_roles.items():
644
+ if source in roles and roles[source] != role:
645
+ raise Fail('图片判断的 source_media role 冲突: %s' % source)
646
+ roles[source] = role
647
+
648
+ defaults = {
649
+ source: decision
650
+ for (source, _, box), decision in scoped.items()
651
+ if box is None
652
+ }
653
+ overrides = {
654
+ (source, layout, box): decision
655
+ for (source, layout, box), decision in scoped.items()
656
+ if box is not None
657
+ }
658
+ return AssetDecisions(
659
+ defaults, overrides, roles,
660
+ {source for source, _, _ in explicit_defaults},
661
+ set(explicit_overrides),
662
+ )
663
+
664
+
665
+ def reusable_decisions_for_source(source, decisions):
666
+ """无布局槽位时,收集该源图所有可能落盘的可复用用途。"""
667
+ reusable = {'background', 'texture', 'logo', 'icon', 'slogan'}
668
+ default = decisions.get(source)
669
+ overrides = {
670
+ decision for (override_source, layout, box), decision in decisions.overrides.items()
671
+ if override_source == source
672
+ and decision in reusable
673
+ and (source not in decisions.default_overrides
674
+ or (override_source, layout, box) in decisions.explicit_overrides)
675
+ }
676
+ return overrides | ({default} if default in reusable else set())
677
+
678
+
679
+ def reusable_decisions_for_slots(source, decisions, source_slots=None):
680
+ """按实际图片槽的最终决定保留资产,可让同图在不同实例按用途绑定。"""
681
+ reusable = {'background', 'texture', 'logo', 'icon', 'slogan'}
682
+ slots = (source_slots or {}).get(source)
683
+ if slots is None:
684
+ return reusable_decisions_for_source(source, decisions)
685
+ return {
686
+ decisions.for_scope(source, layout_name, box)
687
+ for layout_name, box in slots
688
+ } & reusable
689
+
690
+
691
+ def validate_instance_decisions(decisions, bound_slots):
692
+ """实例判断必须指向已有图片槽,避免 layout 拼错后静默退化为通用图片。"""
693
+ if bound_slots is None:
694
+ return
695
+ for source, layout_name, box in decisions.overrides:
696
+ slots = bound_slots.get(source) or set()
697
+ matches = ((layout_name, box) in slots if layout_name is not None
698
+ else any(slot_box == box for _, slot_box in slots))
699
+ if not matches:
700
+ raise Fail('图片判断的实例没有对应图片槽: %s' % source)
701
+
702
+
703
+ def apply_asset_decisions(manifest, bound_sources=None, bound_slots=None):
704
+ """把抽取期图片判断并入正式 assets;内容图与噪声不进入消费产物。"""
705
+ decisions = decision_overrides(manifest)
706
+ if bound_sources is not None:
707
+ declared = set(decisions)
708
+ declared |= {source for source, _, _ in decisions.overrides}
709
+ unbound = sorted(declared - set(bound_sources))
710
+ if unbound:
711
+ raise Fail('图片判断中这些图片没有对应图片槽:%s'
712
+ % '、'.join(unbound))
713
+ validate_instance_decisions(decisions, bound_slots)
714
+
715
+ assets = []
716
+ by_source_kind = {}
717
+ serial = Counter()
718
+ used_ids = {unquote(item.get('id') or '')
719
+ for item in manifest.get('assets') or [] if item.get('id')}
720
+ decision_sources = list(decisions)
721
+ for source, _, _ in decisions.overrides:
722
+ if source not in decision_sources:
723
+ decision_sources.append(source)
724
+ desired_kinds = {
725
+ source: reusable_decisions_for_slots(source, decisions, bound_slots)
726
+ for source in decision_sources
727
+ }
728
+ emitted = set()
729
+
730
+ def next_asset_id(kind):
731
+ serial[kind] += 1
732
+ aid = '%s-%d' % (KIND_PREFIX[kind], serial[kind])
733
+ while aid in used_ids:
734
+ serial[kind] += 1
735
+ aid = '%s-%d' % (KIND_PREFIX[kind], serial[kind])
736
+ used_ids.add(aid)
737
+ return aid
738
+
739
+ def append_asset(item, source, decision):
740
+ old_kind = unquote(item.get('kind') or '')
741
+ pair = (source, decision if source in decision_sources else old_kind)
742
+ if source and pair in emitted:
743
+ return
744
+ row = dict(item)
745
+ if decision and decision != old_kind:
746
+ row['kind'] = decision
747
+ row['id'] = next_asset_id(decision)
748
+ for field in ('role', 'theme', 'on-bg', 'mark'):
749
+ row.pop(field, None)
750
+ if decision == 'background':
751
+ row['role'] = decisions.roles.get(source) or 'content'
752
+ if source:
753
+ emitted.add(pair)
754
+ by_source_kind[(source, row['kind'])] = row
755
+ used_ids.add(unquote(row.get('id') or ''))
756
+ assets.append(row)
757
+
758
+ for item in manifest.get('assets') or []:
759
+ source = unquote(item.get('source_media') or '')
760
+ if source in decision_sources:
761
+ for decision in sorted(desired_kinds[source]):
762
+ append_asset(item, source, decision)
763
+ else:
764
+ append_asset(item, source, None)
765
+
766
+ for source in decision_sources:
767
+ for decision in sorted(desired_kinds[source]):
768
+ if (source, decision) in emitted:
769
+ continue
770
+ append_asset({
771
+ 'id': next_asset_id(decision),
772
+ 'source_media': source,
773
+ 'kind': decision,
774
+ }, source, decision)
775
+ manifest['assets'] = assets
776
+ source_kinds = defaultdict(set)
777
+ for source, kind in by_source_kind:
778
+ source_kinds[source].add(kind)
779
+ defaults = {
780
+ source: unquote(by_source_kind[(source, next(iter(kinds)))]['id'])
781
+ for source, kinds in source_kinds.items()
782
+ if len(kinds) == 1
783
+ }
784
+ scoped = {
785
+ (source, kind): unquote(item['id'])
786
+ for (source, kind), item in by_source_kind.items()
787
+ }
788
+ return decisions, AssetIds(defaults, scoped)
789
+
790
+
287
791
  def media_row_of(extract, source_media):
288
792
  for m in extract.get('media') or []:
289
793
  if m.get('out') and os.path.basename(m['out']) == source_media:
@@ -351,12 +855,25 @@ def place_assets(manifest, extract, stage1, pack):
351
855
  entry['path'] = 'assets/%ss/%s.%s' % (kind, base, cext)
352
856
  if truthy(a.get('use_full')):
353
857
  oext = asset_ext(row['out'])
354
- fdst = os.path.join(sub, '%s@full.%s' % (base, oext))
355
- shutil.copy2(orig_path, fdst)
356
- copied.append(fdst)
357
- entry['full'] = 'assets/%ss/%s@full.%s' % (kind, base, oext)
858
+ if oext.lower() in WEB_SAFE_EXT:
859
+ fdst = os.path.join(sub, '%s@full.%s' % (base, oext))
860
+ shutil.copy2(orig_path, fdst)
861
+ copied.append(fdst)
862
+ entry['full'] = 'assets/%ss/%s@full.%s' % (kind, base, oext)
863
+ else:
864
+ # 原图是浏览器不解码的格式(tiff/bmp 之类)。落进包并在 design.md
865
+ # 里声明成可用资源,消费端引用它就是一张空白图——实测踩过一次,
866
+ # 封面与结束页因此空白。压缩版已经带着全部像素,full 不给。
867
+ print(' ⚠ %s 的原图是 .%s,浏览器不解码,只给压缩版' % (aid, oext))
358
868
  else:
359
869
  oext = asset_ext(row['out'])
870
+ if oext.lower() not in WEB_SAFE_EXT:
871
+ # 转码两条路(Pillow / sips)都没成,原图又是浏览器不解码的格式。
872
+ # 照落进去就是把一张永远显示不出来的图当资产下发——实测封面因此空白。
873
+ raise Fail('%s: 原图是 .%s,浏览器不解码,而转码没有产物'
874
+ '(extract.json 里看 transcode_blocked 的原因)。'
875
+ '装上 Pillow 重跑抽取,或把这条资产从 manifest 删掉'
876
+ '并在 gaps 写明。' % (aid, oext))
360
877
  dst = os.path.join(sub, '%s.%s' % (base, oext))
361
878
  shutil.copy2(orig_path, dst)
362
879
  copied.append(dst)
@@ -382,7 +899,7 @@ def place_assets(manifest, extract, stage1, pack):
382
899
  if boxes:
383
900
  au['boxes'] = boxes
384
901
  # aspect 取**未取整**的 box —— 从取整后的整数反算会明显偏
385
- # (volcano logo 224.1/47.8=4.688,用 224/48 算成 4.667)。
902
+ # 先取整再相除,误差会落到小数点后两位,logo 这种细长框尤其明显。
386
903
  raw = clusters[0]['box']
387
904
  if raw.get('h'):
388
905
  au['aspect'] = round(raw['w'] / float(raw['h']), 3)
@@ -406,6 +923,27 @@ def _pages_of(extract, source_media):
406
923
  return sorted(pages)
407
924
 
408
925
 
926
+ def _archetypes_using(layouts_text, aid, key='asset'):
927
+ """layouts.yaml 里哪些 archetype 引用了这个资产。
928
+
929
+ key='asset' 查 slots 里的图片槽;key='background' 查页型底图。
930
+ """
931
+ out, cur, sect = [], None, None
932
+ for line in (layouts_text or '').split('\n'):
933
+ if re.match(r'^\w[\w-]*:\s*$', line):
934
+ sect = line.split(':')[0]
935
+ continue
936
+ if sect != 'layouts':
937
+ continue
938
+ m = re.match(r'^ ([\w-]+):\s*$', line)
939
+ if m:
940
+ cur = m.group(1)
941
+ continue
942
+ if cur and re.search(r'%s:\s*%s\b' % (key, re.escape(aid)), line) and cur not in out:
943
+ out.append(cur)
944
+ return out
945
+
946
+
409
947
  def expand_placeholders(body, manifest, consumer, audit, extract, layouts_text):
410
948
  """body.md 里的 `{{ASSET_TABLE}}` / `{{LAYOUT_LIST}}` 由本脚本按落盘真值渲染——
411
949
  路径与页型清单是打包期才确定的事实,不该由 L 层手抄(抄错就是死链)。"""
@@ -415,24 +953,35 @@ def expand_placeholders(body, manifest, consumer, audit, extract, layouts_text):
415
953
  for aid, e in consumer.items():
416
954
  pages = _pages_of(extract, src_of.get(aid))
417
955
  box = (audit.get(aid) or {}).get('boxes') or []
418
- if e['kind'] == 'background' and e.get('role') == 'cover':
419
- when = '封面页整幅铺满,必用'
420
- elif e['kind'] == 'background':
421
- when = '内容页整幅铺满——哪个页型用哪张见 `layouts.md` 的 `background`'
422
- elif box:
423
- b = box[0]
424
- when = '固定位 (%d, %d),尺寸 %dx%d px' % tuple(b)
425
- when += (',全 %d 页里只出现在第 %s 页' % (extract['counts']['slides'],
426
- '、'.join(map(str, pages)))
427
- if pages and len(pages) <= 4 else ',每页固定放一次')
956
+ # 单元格保持短句:哪个页型用哪张由 `background` 字段决定,
957
+ # 那句说明放在表格前一次即可,逐行重复只是把同一句抄 N 遍。
958
+ if e['kind'] == 'background':
959
+ users = _archetypes_using(layouts_text, aid, key='background')
960
+ when = ('%s 用它' % '/'.join('`%s`' % o for o in users[:6])
961
+ if users else '未被任何页型引用')
428
962
  else:
429
- when = '按 `%s` 的语义使用' % e['kind']
963
+ # 位置逐页型不同(同一 logo 常在封面左上、内容页右上,尺寸也不同),
964
+ # 所以这里只给归属,坐标一律交给 layouts.md 的 slots。
965
+ owners = _archetypes_using(layouts_text, aid)
966
+ when = ('%s 这 %d 个页型带它' % ('/'.join('`%s`' % o for o in owners[:6]),
967
+ len(owners))
968
+ if owners else '⚠ 没有页型声明它的位置,本包不使用它')
430
969
  f = '`%s`' % e['path'] if e.get('path') else (
431
970
  '`%s`' % e['url'] if e.get('url') else '纯色 `%s`' % e.get('color'))
432
971
  if e.get('full'):
433
972
  f += '(原图 `%s`)' % e['full']
434
973
  rows.append('| `%s` | %s | %s |' % (aid, f, when))
435
974
  body = body.replace('{{ASSET_TABLE}}', '\n'.join(rows))
975
+ if '{{LOGO_RULES}}' in body:
976
+ rows = []
977
+ for aid, entry in consumer.items():
978
+ if entry.get('kind') != 'logo':
979
+ continue
980
+ owners = _archetypes_using(layouts_text, aid)
981
+ rows.append('- `%s` 只出现在这些页型上:%s;其余页型不放。位置取该页型 '
982
+ '`slots` 里 `role: logo` 那一项的 `box`,原样使用该文件、保持原比例。'
983
+ % (aid, '、'.join('`%s`' % owner for owner in owners) or '(无)'))
984
+ body = body.replace('{{LOGO_RULES}}', '\n'.join(rows))
436
985
  if '{{LAYOUT_LIST}}' in body:
437
986
  rows, sect, names = [], None, {}
438
987
  for line in (layouts_text or '').split('\n'):
@@ -466,7 +1015,7 @@ def render_assets_block(consumer):
466
1015
 
467
1016
  # ------------------------------------------------- 数值可追溯机检(抄错即 FAIL)
468
1017
  HEX_RE = re.compile(r'#([0-9A-Fa-f]{6})\b')
469
- FONTSIZE_RE = re.compile(r'\bfontSize:\s*([\d.]+)px')
1018
+ FONTSIZE_RE = re.compile(r'\b(?:fontSize|font-size):\s*([\d.]+)px')
470
1019
  BOX_RE = re.compile(r'\bbox:\s*\[\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*,'
471
1020
  r'\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*\]')
472
1021
  KEYLINE_RE = re.compile(r'^(\s*)-?\s*([A-Za-z_][\w-]*):\s*(.*)$')
@@ -604,6 +1153,47 @@ def _box_hit(box, idx):
604
1153
  % (best[0], best[1], best[2], best[3], best[4], bestd))
605
1154
 
606
1155
 
1156
+ # 判断产物:L 层看着背景图划出来的区域,本就不该命中普查值——整键豁免,
1157
+ # 不要求写 derived。逼它们走 derived 只会把整段豁免掉,机检反而更弱。
1158
+ JUDGEMENT_KEYS = frozenset(('avoid', 'text_safe', 'pairing_rule'))
1159
+
1160
+
1161
+ def media_integrity(stage1, extract):
1162
+ """extract.json 记录的尺寸,必须和磁盘上那个文件真实的尺寸一致。
1163
+
1164
+ 这条挡的是「记录全对、文件被顶替」——两个 media 的输出名撞车时,后写的覆盖先写的,
1165
+ extract.json 里各自的记录都还是对的,任何只读记录的检查都发现不了;只有把记录和
1166
+ 文件本身对一遍才抓得到。实测某模板因此把一张 109x109 的图当成了整页背景。
1167
+ """
1168
+ try:
1169
+ from PIL import Image
1170
+ except Exception:
1171
+ return [] # 没有 Pillow 就量不了,跳过而不是假装通过
1172
+ bad, seen = [], {}
1173
+ for m in extract.get('media') or []:
1174
+ for key, rel in (('source_px', m.get('out')),
1175
+ ('compressed_px', m.get('compressed_out'))):
1176
+ rec = m.get(key)
1177
+ if not rel or not rec:
1178
+ continue
1179
+ path = os.path.join(stage1, rel)
1180
+ if not os.path.exists(path):
1181
+ bad.append('%s 的 %s 指向 %s,文件不存在' % (m['media'], key, rel))
1182
+ continue
1183
+ try:
1184
+ with Image.open(path) as im:
1185
+ real = list(im.size)
1186
+ except Exception:
1187
+ continue
1188
+ if real != list(rec):
1189
+ bad.append('%s 的 %s 记录 %s,但 %s 实际是 %s'
1190
+ % (m['media'], key, rec, rel, real))
1191
+ if rel in seen and seen[rel] != m['media']:
1192
+ bad.append('%s 与 %s 都写到 %s' % (seen[rel], m['media'], rel))
1193
+ seen[rel] = m['media']
1194
+ return bad
1195
+
1196
+
607
1197
  def trace_check(pack, extract, shapes, derived_values, derived_tokens, factor):
608
1198
  """产物里每个 hex / 字号 / slot 坐标都必须可追溯到普查值或 derived 声明。"""
609
1199
  idx = build_trace_index(extract, shapes)
@@ -614,6 +1204,9 @@ def trace_check(pack, extract, shapes, derived_values, derived_tokens, factor):
614
1204
  if not text:
615
1205
  continue
616
1206
  for lineno, path, raw in _scan_keyed(text):
1207
+ if JUDGEMENT_KEYS & set(path):
1208
+ checked['judgement'] += 1
1209
+ continue
617
1210
  exempt_token = any(k in derived_tokens for k in path)
618
1211
  for m in HEX_RE.finditer(raw):
619
1212
  val = '#' + m.group(1).upper()
@@ -762,106 +1355,6 @@ def build_package_manifest(manifest, consumer, audit, extract, pack):
762
1355
  return {k: v for k, v in pkg.items() if v is not None}
763
1356
 
764
1357
 
765
- def layout_fast_index(layouts_blocks, max_slots=4):
766
- if not layouts_blocks:
767
- return []
768
- blocks = {k: (inline, lines) for k, inline, lines in layouts_blocks}
769
- if 'layouts' not in blocks:
770
- return []
771
- items = []
772
- cur = None
773
- for line in blocks['layouts'][1]:
774
- m = re.match(r'^ ([\w-]+):\s*$', line)
775
- if m:
776
- cur = {'id': m.group(1), 'name': '', 'role': '', 'background': '', 'slots': []}
777
- items.append(cur)
778
- continue
779
- if not cur:
780
- continue
781
- m = re.match(r'^ (name|role|background):\s*(.+?)\s*$', line)
782
- if m:
783
- cur[m.group(1)] = unquote(m.group(2))
784
- continue
785
- m = re.match(r'^\s{6}-\s*\{(.+)\}\s*$', line)
786
- if m and len(cur['slots']) < max_slots:
787
- raw = m.group(1)
788
- role = re.search(r'role:\s*([^,}]+)', raw)
789
- box = re.search(r'box:\s*\[([^\]]+)\]', raw)
790
- typ = re.search(r'type:\s*([^,}]+)', raw)
791
- if role and box:
792
- label = unquote(role.group(1).strip())
793
- if typ:
794
- label += '/' + unquote(typ.group(1).strip())
795
- cur['slots'].append('%s [%s]' % (label, box.group(1).strip()))
796
- out = []
797
- for item in items:
798
- parts = ['`%s`' % item['id']]
799
- if item.get('name'):
800
- parts.append(item['name'])
801
- if item.get('role'):
802
- parts.append(item['role'])
803
- if item.get('background'):
804
- parts.append('背景 `%s`' % item['background'])
805
- slots = ';'.join(item['slots'])
806
- out.append('%s:%s' % (' / '.join(parts), slots or '按最接近用途套用'))
807
- return out
808
-
809
-
810
- def render_fast_path(manifest, consumer, has_sidecar, canvas, layouts_index=None):
811
- """Render the small consumer-first block that keeps runtime agents out of ref/."""
812
- cover = next((aid for aid, a in consumer.items()
813
- if a.get('kind') == 'background' and a.get('role') == 'cover'), None)
814
- content_bgs = [aid for aid, a in consumer.items()
815
- if a.get('kind') == 'background' and a.get('role') == 'content']
816
- logos = [aid for aid, a in consumer.items() if a.get('kind') in ('logo', 'slogan')]
817
- assets = []
818
- for aid, a in consumer.items():
819
- path = a.get('path') or a.get('color') or a.get('url')
820
- role = a.get('role') or a.get('kind')
821
- if path:
822
- assets.append('`%s` -> `%s` (%s)' % (aid, path, role))
823
- if len(assets) > 8:
824
- assets = assets[:8] + ['其余资产见 frontmatter `assets`,不要去 `ref/` 里临时挑图。']
825
-
826
- lines = [
827
- '## Agent Fast Path',
828
- '',
829
- '消费本风格时先读这一节;它是给生成 Agent 的短路径,目标是把风格理解控制在 1 分钟内,避免把审计材料重新理解一遍。',
830
- '',
831
- '- **时间预算**:风格导入最多做 1 次 `read_file design.md`。本节已经内联常用坐标,读完后必须直接开始生成,不要再探索风格包。',
832
- '- **只读入口**:常规生成只需要 `design.md`。只有本节的版式索引无法覆盖目标页时,才打开 %s。'
833
- % ('`layouts.md`' if has_sidecar else '`design.md` 里的 `layouts`'),
834
- '- **附件/zip 兜底**:如果当前内容来自 zip 附件的文本摘要,直接使用摘要中 `design.md` / `layouts.md` 的文本;不要尝试修复 zip、解析二进制、搜索附件目录或重建压缩包。',
835
- '- **禁止动作**:不要读取 `ref/color-freq-raw.json`、`ref/font-clusters.json`、`ref/extract.json`、`ref/rebuild/`、`ref/rebuild/png/*`;不要 summarize / view 参考页;不要重新统计颜色、字体或版式。',
836
- '- **信息来源优先级**:本节 > `## Usage` > frontmatter `assets` / `colors` / `typography` > `layouts.md`。除此之外的文件只用于人工审计,不用于生成。',
837
- '- **缺信息时降级**:如果某个细节本节没有写,用 frontmatter token 和最接近的 `layouts.md` archetype 推断;不要打开审计文件补证。',
838
- ]
839
- if canvas:
840
- lines.append('- **画布**:所有坐标按 `%dx%d` 绝对像素理解。' % tuple(canvas))
841
- if cover:
842
- lines.append('- **封面背景**:优先使用 `%s`;整幅铺满画布,禁止自造渐变替代。' % cover)
843
- if content_bgs:
844
- lines.append('- **内容页背景**:按 `layouts.md` 中 archetype 的 `background` 字段取;常用内容背景为 %s。'
845
- % '、'.join('`%s`' % x for x in content_bgs[:4]))
846
- if cover or content_bgs:
847
- lines.append('- **背景安全区**:背景图和版式必须配对。按 archetype 的 `background`、`text_safe`、`avoid` 一起放文字和卡片;标题、正文、关键数字、图表、卡片、时间线及其容器的外接矩形都不得压到背景视觉主体、强光斑、深色透明区上,透明容器也不能跨进禁放区。')
848
- if logos:
849
- lines.append('- **标识资产**:只使用 %s;不得重画、不得改比例。'
850
- % '、'.join('`%s`' % x for x in logos))
851
- lines += [
852
- '- **配色与字体**:颜色只取 frontmatter `colors`;字体/字号只取 frontmatter `typography`;内容主题不得引入新色相。',
853
- '- **版式**:优先使用下面内联版式索引;需要更多 slot 时才读 `layouts.md`;不要用 `ref/rebuild/png` 反推坐标。',
854
- ]
855
- if layouts_index:
856
- lines += ['', '内联版式索引(先用这里,不要为了选页型再读文件):']
857
- lines += ['- ' + x for x in layouts_index]
858
- if assets:
859
- lines += ['', '关键资产:']
860
- lines += ['- ' + a for a in assets]
861
- lines.append('')
862
- return '\n'.join(lines) + '\n'
863
-
864
-
865
1358
  def strip_agent_fast_path(body):
866
1359
  if '## Agent Fast Path' not in body:
867
1360
  return body
@@ -869,8 +1362,7 @@ def strip_agent_fast_path(body):
869
1362
  count=1, flags=re.S).lstrip('\n')
870
1363
 
871
1364
 
872
- def build_design(manifest, l_frontmatter, consumer, body, has_sidecar, canvas,
873
- layouts_index=None):
1365
+ def build_design(manifest, l_frontmatter, consumer, body, has_sidecar, canvas):
874
1366
  blocks = {k: (inline, lines) for k, inline, lines in l_frontmatter}
875
1367
  bad = set(blocks) - L_FRONTMATTER_KEYS
876
1368
  if bad:
@@ -908,9 +1400,9 @@ def build_design(manifest, l_frontmatter, consumer, body, has_sidecar, canvas,
908
1400
  out.append('canvas: %dx%d' % tuple(canvas))
909
1401
  out.append('---')
910
1402
  text = '\n'.join(out) + '\n'
1403
+ # Fast Path 已废弃:它是 Usage / Hard Rules / 资产表 / 页型清单的第二份副本。
1404
+ # 规则的唯一出处是正文各段,这里只负责把历史产物里的残留剥掉。
911
1405
  body = strip_agent_fast_path(body)
912
- text += '\n' + render_fast_path(manifest, consumer, has_sidecar, canvas,
913
- layouts_index=layouts_index)
914
1406
  if body:
915
1407
  text += body if body.startswith('\n') else '\n' + body
916
1408
  if not text.endswith('\n'):
@@ -918,26 +1410,324 @@ def build_design(manifest, l_frontmatter, consumer, body, has_sidecar, canvas,
918
1410
  return text
919
1411
 
920
1412
 
921
- def build_layouts_md(layouts_blocks, canvas):
1413
+ def layout_forms(lines):
1414
+ """每个页型草案里实际给了哪几种形态(flow / slots)。"""
1415
+ out, cur = {}, None
1416
+ for line in lines:
1417
+ m = re.match(r'^ ([\w-]+):\s*$', line)
1418
+ if m:
1419
+ cur = m.group(1)
1420
+ out[cur] = set()
1421
+ continue
1422
+ m = re.match(r'^ (flow|slots):\s*$', line)
1423
+ if m and cur:
1424
+ out[cur].add(m.group(1))
1425
+ return out
1426
+
1427
+
1428
+ def layout_modes(lines):
1429
+ """读取扁平 `layout_modes:` 判断区。"""
1430
+ out = {}
1431
+ for line in lines:
1432
+ m = re.match(r'^\s{2}([\w-]+):\s*([^\s#]+)', line)
1433
+ if m:
1434
+ out[m.group(1)] = m.group(2).strip()
1435
+ return out
1436
+
1437
+
1438
+ def text_role_boxes(lines):
1439
+ """从 slots 里的同名标记取回文本槽坐标,供 flow 固定锚点复用。"""
1440
+ out, pending = {}, None
1441
+ for line in lines:
1442
+ marker = re.match(r'^\s*#\s*text-role:\s*([\w-]+)\s*$', line)
1443
+ if marker:
1444
+ pending = marker.group(1)
1445
+ continue
1446
+ if pending:
1447
+ box = re.search(r'\bbox:\s*(\[[^\]]+\])', line)
1448
+ if box:
1449
+ out[pending] = box.group(1)
1450
+ pending = None
1451
+ return out
1452
+
1453
+
1454
+ def select_layout_forms(lines, modes):
1455
+ """按 `layout_modes` 只保留每个页型选中的 flow 或 slots。"""
1456
+ forms = layout_forms(lines)
1457
+ out, layout, drop = [], None, False
1458
+ for line in lines:
1459
+ layout_match = re.match(r'^ ([\w-]+):\s*$', line)
1460
+ form_match = re.match(r'^ (flow|slots):\s*$', line)
1461
+ if layout_match:
1462
+ layout, drop = layout_match.group(1), False
1463
+ elif form_match:
1464
+ selected = modes.get(layout)
1465
+ if selected is None and len(forms.get(layout) or ()) >= 2:
1466
+ selected = 'slots'
1467
+ drop = selected is not None and selected != form_match.group(1)
1468
+ elif drop and re.match(r'^ \S', line):
1469
+ drop = False
1470
+ if not drop:
1471
+ out.append(line)
1472
+ return out
1473
+
1474
+
1475
+ def shrink_safe_area(lines):
1476
+ """text_safe 与 avoid 相交时把安全区收掉重叠的那部分。
1477
+
1478
+ text_safe 是脚本按「该背景各页型的槽位并集」算的,而模板里的槽位本身可能就压在
1479
+ 主视觉上;avoid 是看图的人填的。两者从不同来源来,会直接打架——实测一张背景的
1480
+ text_safe 有 34% 落在 avoid 里,消费端按 text_safe 把标题放进了禁放区,正好压在
1481
+ 背景的山峰主体上。这里以 avoid 为准收缩:能不能放字是看图的人说了算。
1482
+ """
1483
+ def parse_box(s):
1484
+ m = re.findall(r'-?\d+', s)
1485
+ return [int(x) for x in m[:4]] if len(m) >= 4 else None
1486
+
1487
+ ts_i = ts = None
1488
+ avoids = []
1489
+ for i, ln in enumerate(lines):
1490
+ st = ln.strip()
1491
+ if st.startswith('text_safe:'):
1492
+ ts_i, ts = i, parse_box(st.split(':', 1)[1].split('#')[0])
1493
+ elif st.startswith('avoid:'):
1494
+ for chunk in re.findall(r'box:\s*\[[^\]]*\]', st):
1495
+ b = parse_box(chunk)
1496
+ if b:
1497
+ avoids.append(b)
1498
+ if ts is None or not avoids:
1499
+ return lines, None
1500
+ x0, y0, x1, y1 = ts[0], ts[1], ts[0] + ts[2], ts[1] + ts[3]
1501
+ for a in avoids:
1502
+ ax0, ay0, ax1, ay1 = a[0], a[1], a[0] + a[2], a[1] + a[3]
1503
+ if ax1 <= x0 or ax0 >= x1 or ay1 <= y0 or ay0 >= y1:
1504
+ continue # 不相交
1505
+ # 从被侵占得最少的一边切:优先保留面积最大的剩余矩形
1506
+ cands = []
1507
+ if ax0 > x0:
1508
+ cands.append((x0, y0, ax0, y1))
1509
+ if ax1 < x1:
1510
+ cands.append((ax1, y0, x1, y1))
1511
+ if ay0 > y0:
1512
+ cands.append((x0, y0, x1, ay0))
1513
+ if ay1 < y1:
1514
+ cands.append((x0, ay1, x1, y1))
1515
+ if not cands:
1516
+ return lines, 'text_safe 被 avoid 完全覆盖,这张背景没有可放文字的区域'
1517
+ x0, y0, x1, y1 = max(cands, key=lambda c: (c[2] - c[0]) * (c[3] - c[1]))
1518
+ new = [x0, y0, x1 - x0, y1 - y0]
1519
+ if new == ts:
1520
+ return lines, None
1521
+ lines = list(lines)
1522
+ lines[ts_i] = ' text_safe: [%d, %d, %d, %d] # 已按 avoid 收缩(原 %s)' % (
1523
+ new[0], new[1], new[2], new[3], ts)
1524
+ return lines, None
1525
+
1526
+
1527
+ def resolve_asset_candidate_lines(lines, decisions, asset_ids):
1528
+ """把 source_media 临时字段转成资产引用;内容图保留通用 pic 槽。"""
1529
+ out = []
1530
+ content_slots = set()
1531
+ layout_name, layout_form = None, None
1532
+ for line in lines:
1533
+ layout_match = re.match(r'^ ([\w-]+):\s*$', line)
1534
+ form_match = re.match(r'^ (flow|slots):\s*$', line)
1535
+ if layout_match:
1536
+ layout_name, layout_form = layout_match.group(1), None
1537
+ elif form_match:
1538
+ layout_form = form_match.group(1)
1539
+ match = re.search(r'\bsource_media:\s*([^,}]+)', line)
1540
+ if not match:
1541
+ out.append(line)
1542
+ continue
1543
+ source = unquote(match.group(1).strip())
1544
+ candidate_box = source_box(line) or slot_box(line)
1545
+ decision = (decisions.for_slot(source, line, layout_name)
1546
+ if isinstance(decisions, AssetDecisions)
1547
+ else decisions.get(source))
1548
+ line = re.sub(r'\s*,?\s*source_media:\s*[^,}]+', '', line, count=1)
1549
+ line = re.sub(r'\s*,?\s*source_box:\s*\[[^\]]+\]', '', line, count=1)
1550
+ # 预算裁掉、没有进入视觉组的素材不凭空判成风格资产;保留原位置通用图片槽,
1551
+ # 让生成侧可放这一页的内容图。`omit` 是页码/水印等纯噪声,直接去掉槽。
1552
+ if decision == 'omit':
1553
+ continue
1554
+ if decision in (None, 'content'):
1555
+ line = re.sub(r'\s*,?\s*asset:\s*[^,}]+', '', line, count=1)
1556
+ line = re.sub(r'(\brole:\s*)[\w-]+', r'\g<1>pic', line, count=1)
1557
+ # 内容图不进风格资产,但同一位置只能留一个通用图片槽。不同图片源在同一
1558
+ # box 的叠加是原 PPT 的内容选择,不应被转换成两个生成时必然重叠的 pic 槽。
1559
+ content_key = (layout_name, layout_form, candidate_box or line.strip())
1560
+ if content_key in content_slots:
1561
+ continue
1562
+ content_slots.add(content_key)
1563
+ out.append(line)
1564
+ continue
1565
+ aid = (asset_ids.for_decision(source, decision)
1566
+ if isinstance(asset_ids, AssetIds) else asset_ids.get(source))
1567
+ if not aid:
1568
+ raise Fail('layouts.yaml 引用了未完成资产判断的 source_media: %s' % source)
1569
+ if re.search(r'\basset:\s*[^,}]+', line):
1570
+ line = re.sub(r'(\basset:\s*)[^,}]+', r'\g<1>%s' % aid, line, count=1)
1571
+ else:
1572
+ line = line.replace('}', ', asset: %s}' % aid, 1)
1573
+ line = re.sub(r'(\brole:\s*)[\w-]+', r'\g<1>%s' % decision, line, count=1)
1574
+ out.append(line)
1575
+ return out
1576
+
1577
+
1578
+ def build_layouts_md(layouts_blocks, canvas, asset_decisions=None, asset_ids=None):
922
1579
  blocks = {k: (inline, lines) for k, inline, lines in layouts_blocks}
923
1580
  if 'canvas' in blocks:
924
1581
  raise Fail('layouts.yaml 不要写 canvas —— 脚本从 extract.json 取')
925
1582
  if 'layouts' not in blocks:
926
1583
  raise Fail('layouts.yaml 缺顶层键 `layouts:`')
927
- # `names:` 是给 L 层集中改中文页型名的一块——在这里并回各 archetype,不进产物
928
- names = {}
929
- for line in blocks.get('names', ('', []))[1]:
930
- m = re.match(r'^\s{2}([\w-]+):\s*(.+?)\s*$', line)
1584
+ # `names:` / `roles:` / `text_roles:` / `layout_modes:` / `bg_rules:` 是给 L 层集中填判断的
1585
+ # 扁平区——在这里并回各 archetype,本身不进产物。让 L 层只改扁平键值,别去动 layouts 里的
1586
+ # slots/confidence 结构(嵌套结构手改极易破坏缩进,进而静默改变语义)。
1587
+ names, roles = {}, {}
1588
+ for key, sink in (('names', names), ('roles', roles)):
1589
+ for line in blocks.get(key, ('', []))[1]:
1590
+ m = re.match(r'^\s{2}([\w-]+):\s*(.+?)\s*$', line)
1591
+ if m:
1592
+ sink[m.group(1)] = unquote(m.group(2))
1593
+ text_roles = {}
1594
+ allowed_text_roles = {'title', 'subtitle', 'header', 'footer', 'body'}
1595
+ for line in blocks.get('text_roles', ('', []))[1]:
1596
+ m = re.match(r'^\s{2}([\w-]+):\s*([A-Za-z-]+)(?:\s+#.*)?$', line)
1597
+ if not m:
1598
+ continue
1599
+ role_id, role = m.groups()
1600
+ if role not in allowed_text_roles:
1601
+ raise Fail('text_roles.%s 取值 %s 非法;应为 %s'
1602
+ % (role_id, role, '|'.join(sorted(allowed_text_roles))))
1603
+ text_roles[role_id] = role
1604
+ bg_rules, cur = {}, None
1605
+ for line in blocks.get('bg_rules', ('', []))[1]:
1606
+ # 键后面允许行内注释(草案会标「用它的页型:…」)
1607
+ m = re.match(r'^\s{2}([\w-]+):\s*(?:#.*)?$', line)
931
1608
  if m:
932
- names[m.group(1)] = unquote(m.group(2))
933
- out = ['---', 'canvas: %dx%d' % tuple(canvas), 'layouts:']
934
- for line in blocks['layouts'][1]:
935
- out.append(line)
1609
+ cur = m.group(1)
1610
+ bg_rules[cur] = []
1611
+ continue
1612
+ if cur and line.strip():
1613
+ bg_rules[cur].append(' ' + line.strip())
1614
+ out = ['---', 'canvas: %dx%d' % tuple(canvas)]
1615
+ # 禁放区是背景的属性,按背景写一次;页型只留 `background:` 指针。
1616
+ # 合并进每个页型会把同一句话按页型数复制 N 遍。
1617
+ if bg_rules:
1618
+ out.append('backgrounds:')
1619
+ for bg, lines in bg_rules.items():
1620
+ lines, err = shrink_safe_area(lines)
1621
+ if err:
1622
+ raise Fail('%s: %s' % (bg, err))
1623
+ out.append(' %s:' % bg)
1624
+ out += lines
1625
+ modes = layout_modes(blocks.get('layout_modes', ('', []))[1])
1626
+ decided_layout_roles = set(roles)
1627
+ out.append('layouts:')
1628
+ pending_text_role = None
1629
+ used_text_roles = set()
1630
+ decisions = asset_decisions if asset_decisions is not None else {}
1631
+ resolved_asset_ids = asset_ids if asset_ids is not None else {}
1632
+ source_layout_lines = resolve_asset_candidate_lines(
1633
+ blocks['layouts'][1], decisions, resolved_asset_ids)
1634
+ role_boxes = text_role_boxes(source_layout_lines)
1635
+ layout_lines = select_layout_forms(source_layout_lines, modes)
1636
+ cur, fixed_items = None, []
1637
+ emitted_roles = set()
1638
+
1639
+ def flush_fixed_items():
1640
+ if not fixed_items:
1641
+ return
1642
+ out.append(' - kind: free')
1643
+ out.append(' items:')
1644
+ out.extend(fixed_items)
1645
+ fixed_items.clear()
1646
+
1647
+ for line in layout_lines:
936
1648
  m = re.match(r'^ ([\w-]+):\s*$', line)
1649
+ if m:
1650
+ flush_fixed_items()
1651
+ cur = m.group(1)
1652
+ elif fixed_items and re.match(r'^ \S', line):
1653
+ flush_fixed_items()
1654
+ if cur in decided_layout_roles and re.match(r'^ role:\s*', line):
1655
+ # 页型草案中的 role 只是初始值;最终语义只由扁平 roles 判断单决定。
1656
+ # 否则两者同时写入会产生重复 YAML 键,并让后面的草案默认值覆盖模型判断。
1657
+ continue
1658
+ # 判断单里的结构事实(栅格、间距序列、样张字数、命中配方)是给 L 层判断用的,
1659
+ # 不进产物——消费端要的是结论,不是推导过程。layout_mode 同理,它是判断的载体。
1660
+ if line.lstrip().startswith('#'):
1661
+ marker = re.match(r'^\s*#\s*text-role:\s*([\w-]+)\s*$', line)
1662
+ if marker:
1663
+ pending_text_role = marker.group(1)
1664
+ continue
1665
+ if pending_text_role:
1666
+ role = text_roles.get(pending_text_role)
1667
+ if role:
1668
+ # header 是语义角色,V2 没有同名的渲染 slot type;保留 role 供消费端
1669
+ # 识别固定页眉,同时用 body 通过 V2 的类型枚举。
1670
+ slot_type = role if role in ('title', 'subtitle', 'footer') else 'body'
1671
+ line, role_n = re.subn(r'(\{\s*role:\s*)[\w-]+',
1672
+ r'\g<1>%s' % role, line, count=1)
1673
+ line, type_n = re.subn(r'(\btype:\s*)[\w-]+',
1674
+ r'\g<1>%s' % slot_type, line, count=1)
1675
+ if role_n != 1 or type_n != 1:
1676
+ raise Fail('text_roles.%s 没有命中一个文本槽' % pending_text_role)
1677
+ if (modes.get(cur) == 'flow' and role in ('header', 'footer')
1678
+ and re.match(r'^\s{12}-\s*\{', line) and 'box:' not in line):
1679
+ box = role_boxes.get(pending_text_role)
1680
+ if not box:
1681
+ raise Fail('text_roles.%s 识别为 %s,但 slots 中没有坐标'
1682
+ % (pending_text_role, role))
1683
+ line = re.sub(r',\s*type:', ', box: %s, type:' % box, line, count=1)
1684
+ fixed_items.append(line)
1685
+ used_text_roles.add(pending_text_role)
1686
+ pending_text_role = None
1687
+ continue
1688
+ used_text_roles.add(pending_text_role)
1689
+ pending_text_role = None
1690
+ out.append(line)
1691
+ if cur and re.match(r'^ role:\s*', line):
1692
+ emitted_roles.add(cur)
937
1693
  if m and m.group(1) in names:
938
1694
  out.append(' name: "%s"' % names.pop(m.group(1)))
1695
+ if m and m.group(1) in roles:
1696
+ out.append(' role: %s' % roles.pop(m.group(1)))
1697
+ emitted_roles.add(m.group(1))
1698
+ flush_fixed_items()
939
1699
  if names:
940
1700
  raise Fail('names 里这些页型在 layouts 下找不到:%s' % ', '.join(sorted(names)))
1701
+ if roles:
1702
+ raise Fail('roles 里这些页型在 layouts 下找不到:%s' % ', '.join(sorted(roles)))
1703
+ unused_text_roles = set(text_roles) - used_text_roles
1704
+ if unused_text_roles:
1705
+ raise Fail('text_roles 里这些判断没有命中文本槽:%s'
1706
+ % ', '.join(sorted(unused_text_roles)))
1707
+ missing_role = [
1708
+ k for k in re.findall(r'^ ([\w-]+):\s*$', '\n'.join(blocks['layouts'][1]), re.M)
1709
+ if k not in emitted_roles
1710
+ ]
1711
+ if missing_role:
1712
+ raise Fail('这些页型没有 role(在 layouts.yaml 的 roles 段填):%s' % ', '.join(missing_role))
1713
+ # 未声明 layout_modes 时默认 slots:固定坐标是从原 PPT 直接普查的安全交付形态。
1714
+ # 模型只在看样张确认内容需要随高度重排时显式改为 flow。
1715
+ forms = layout_forms(blocks['layouts'][1])
1716
+ bad = []
1717
+ for k, v in sorted(modes.items()):
1718
+ if len(forms.get(k) or ()) < 2:
1719
+ continue # 只有一份形态,无从选择
1720
+ if v not in ('flow', 'slots'):
1721
+ bad.append('%s 的 layout_modes 判断是 %r' % (k, v))
1722
+ elif v not in forms[k]:
1723
+ bad.append('%s 选了 %s 但该页型没有这一份' % (k, v))
1724
+ if bad:
1725
+ raise Fail('layout_modes 只能填 flow 或 slots,一个页型一个词:%s' % ';'.join(bad))
1726
+ declared = set(re.findall(r'^ background:\s*(\S+)\s*$',
1727
+ '\n'.join(blocks['layouts'][1]), re.M))
1728
+ stray = set(bg_rules) - declared
1729
+ if stray:
1730
+ raise Fail('backgrounds 段里这些背景没有任何页型在用:%s' % ', '.join(sorted(stray)))
941
1731
  out.append('---')
942
1732
  text = '\n'.join(out) + '\n'
943
1733
  if 'body' in blocks:
@@ -983,7 +1773,13 @@ def main(argv=None):
983
1773
  raise Fail('%s 非空;加 --force 覆盖' % pack)
984
1774
 
985
1775
  left = []
986
- for f in ('manifest.yaml', 'frontmatter.yaml', 'layouts.yaml', 'body.md'):
1776
+ control_path = os.path.join(lout, 'layout-controls.yaml')
1777
+ draft_files = ['manifest.yaml', 'frontmatter.yaml', 'body.md']
1778
+ # 新草案的可编辑判断已移到小型控制文件;layouts.yaml 中保留的旧控制区只是
1779
+ # 兼容坐标事实,仍含 TODO 也不会进入最终 layouts.md。
1780
+ draft_files.append('layout-controls.yaml' if os.path.exists(control_path)
1781
+ else 'layouts.yaml')
1782
+ for f in draft_files:
987
1783
  p = os.path.join(lout, f)
988
1784
  if not os.path.exists(p):
989
1785
  continue
@@ -993,8 +1789,15 @@ def main(argv=None):
993
1789
  if left:
994
1790
  raise Fail('判断单还有 %d 处草案占位没改(TODO 是 draft.py 留给 L 层的判断点):\n %s'
995
1791
  % (len(left), '\n '.join(left)))
1792
+ mdesc = re.search(r'^description:\s*[|>]?\s*\n((?:\s+\S.*\n?)+)',
1793
+ open(os.path.join(lout, 'manifest.yaml'), encoding='utf-8').read(), re.M)
1794
+ if mdesc and len(re.sub(r'\s+', '', mdesc.group(1))) < 20:
1795
+ raise Fail('manifest 的 description 只有 %d 个字符——它是消费模型定调的唯一入口,'
1796
+ '写清底色/主色/字形/版面骨架,不要留占位'
1797
+ % len(re.sub(r'\s+', '', mdesc.group(1))))
996
1798
 
997
1799
  manifest = read_manifest(os.path.join(lout, 'manifest.yaml'))
1800
+ bind_asset_vision_group_scopes(lout, manifest)
998
1801
  if args.style_name:
999
1802
  manifest['name'] = args.style_name
1000
1803
  if not manifest.get('name'):
@@ -1006,44 +1809,55 @@ def main(argv=None):
1006
1809
  l_fm = split_top_blocks(open(os.path.join(lout, 'frontmatter.yaml'),
1007
1810
  encoding='utf-8').read())
1008
1811
  body = open(os.path.join(lout, 'body.md'), encoding='utf-8').read()
1009
- lay_path = os.path.join(lout, 'layouts.yaml')
1010
- layouts_blocks = (split_top_blocks(open(lay_path, encoding='utf-8').read())
1011
- if os.path.exists(lay_path) else None)
1812
+ layouts_blocks = load_layout_blocks(lout)
1813
+ bound_sources, bound_slots = set(), defaultdict(set)
1814
+ if layouts_blocks is not None:
1815
+ for key, _, lines in layouts_blocks:
1816
+ layout_name = None
1817
+ for line in lines:
1818
+ layout_match = re.match(r'^ ([\w-]+):\s*$', line)
1819
+ if layout_match:
1820
+ layout_name = layout_match.group(1)
1821
+ match = re.search(r'\bsource_media:\s*([^,}]+)', line)
1822
+ if match:
1823
+ source = unquote(match.group(1).strip())
1824
+ bound_sources.add(source)
1825
+ box = source_box(line) or slot_box(line)
1826
+ if key == 'layouts' and box is not None:
1827
+ bound_slots[source].add((layout_name, box))
1828
+ has_asset_judgments = bool(
1829
+ manifest.get('asset_vision_groups') or manifest.get('asset_decisions'))
1830
+ asset_decisions, asset_ids = apply_asset_decisions(
1831
+ manifest,
1832
+ bound_sources=bound_sources if has_asset_judgments else None,
1833
+ bound_slots=bound_slots if has_asset_judgments else None)
1012
1834
  canvas = extract['canvas']['px']
1835
+ final_layouts = (build_layouts_md(
1836
+ layouts_blocks, canvas, asset_decisions=asset_decisions, asset_ids=asset_ids)
1837
+ if layouts_blocks is not None else None)
1013
1838
 
1014
1839
  os.makedirs(pack, exist_ok=True)
1015
1840
  consumer, audit, copied = place_assets(manifest, extract, stage1, pack)
1016
1841
 
1017
- lay_text = open(lay_path, encoding='utf-8').read() if os.path.exists(lay_path) else ''
1842
+ lay_text = final_layouts or ''
1018
1843
  body = expand_placeholders(body, manifest, consumer, audit, extract, lay_text)
1019
- layouts_index = layout_fast_index(layouts_blocks)
1020
1844
  design = build_design(manifest, l_fm, consumer, body,
1021
- has_sidecar=layouts_blocks is not None, canvas=canvas,
1022
- layouts_index=layouts_index)
1845
+ has_sidecar=layouts_blocks is not None, canvas=canvas)
1023
1846
  with open(os.path.join(pack, 'design.md'), 'w', encoding='utf-8') as f:
1024
1847
  f.write(design)
1025
1848
  if layouts_blocks is not None:
1026
1849
  with open(os.path.join(pack, 'layouts.md'), 'w', encoding='utf-8') as f:
1027
- f.write(build_layouts_md(layouts_blocks, canvas))
1850
+ f.write(final_layouts)
1028
1851
 
1029
- ref = os.path.join(pack, 'ref')
1852
+ # 审计记录写回**抽取工作目录**,不进交付包:包是消费产物,`ref/` 里的东西
1853
+ # 没有任何门禁读、消费模型也用不上,放进去只会让「别读我」和「几 MB 材料」同时下发。
1854
+ ref = os.path.join(stage1, 'ref')
1030
1855
  os.makedirs(ref, exist_ok=True)
1031
- with open(os.path.join(ref, 'audit.yaml'), 'w', encoding='utf-8') as f:
1856
+ audit_path = os.path.join(ref, 'audit.yaml')
1857
+ with open(audit_path, 'w', encoding='utf-8') as f:
1032
1858
  f.write(render_audit(manifest, extract, audit))
1033
1859
  carried = []
1034
- for name in REF_CARRY:
1035
- srcf = os.path.join(stage1, 'ref', name)
1036
- if os.path.exists(srcf):
1037
- shutil.copy2(srcf, os.path.join(ref, name))
1038
- carried.append(name)
1039
- shutil.copy2(ex_path, os.path.join(ref, 'extract.json'))
1040
- carried.append('extract.json')
1041
- for sub in ('rebuild', 'logo-candidates'):
1042
- s = os.path.join(stage1, 'ref', sub)
1043
- if os.path.isdir(s):
1044
- shutil.copytree(s, os.path.join(ref, sub), dirs_exist_ok=True)
1045
- carried.append(sub + '/')
1046
- # L 层自备的 ref 补充件(判断理由、版式溯源等)原样带入
1860
+ # L 层自备的 ref 补充件(判断理由、版式溯源等)也归到抽取目录
1047
1861
  l_ref = os.path.join(lout, 'ref')
1048
1862
  if os.path.isdir(l_ref):
1049
1863
  shutil.copytree(l_ref, ref, dirs_exist_ok=True)
@@ -1061,7 +1875,9 @@ def main(argv=None):
1061
1875
  print(' assets %d 条目 / %d 文件落盘' % (len(consumer), len(copied)))
1062
1876
  for d in copied:
1063
1877
  print(' %-52s %8d B' % (os.path.relpath(d, pack), os.path.getsize(d)))
1064
- print(' ref/ %s' % ', '.join(carried))
1878
+ print(' 审计记录 -> %s%s(不进交付包)'
1879
+ % (os.path.relpath(audit_path, os.path.dirname(stage1)),
1880
+ ' ' + ', '.join(carried) if carried else ''))
1065
1881
 
1066
1882
  # ---- 数值可追溯机检:产物里每个色值/字号/坐标都得能指回普查值
1067
1883
  derived_values, derived_tokens = set(), set()
@@ -1079,9 +1895,17 @@ def main(argv=None):
1079
1895
  shapes = json.load(open(sp, encoding='utf-8'))['shapes'] if os.path.exists(sp) else []
1080
1896
  problems, checked = trace_check(pack, extract, shapes,
1081
1897
  derived_values, derived_tokens, factor)
1898
+ integrity = media_integrity(stage1, extract)
1899
+ if integrity:
1900
+ print('\n--- 媒体产物完整性 ---')
1901
+ for b in integrity:
1902
+ print(' FAIL ' + b)
1903
+ raise Fail('media-out 里的文件和 extract.json 的记录对不上(%d 处)' % len(integrity))
1082
1904
  print('\n--- 数值可追溯机检 ---')
1083
- print(' 受检 hex %d / fontSize %d / slot box %d;derived 豁免 %d%s'
1905
+ print(' 受检 hex %d / fontSize %d / slot box %d;derived 豁免 %d%s%s'
1084
1906
  % (checked['hex'], checked['size'], checked['box'], checked['exempt'],
1907
+ ';判断键跳过 %d 行(avoid/text_safe/pairing_rule)' % checked['judgement']
1908
+ if checked['judgement'] else '',
1085
1909
  ';rebase_factor=%g' % factor if factor else ''))
1086
1910
  if not shapes:
1087
1911
  print(' ⚠ 未找到 %s,slot 坐标一项无法校验' % sp)
@@ -1108,11 +1932,13 @@ def main(argv=None):
1108
1932
  print('check_v1: 未探测到($DSM_V1_DIR / 同级 skill / 开发机路径均无)—— v1 门禁未跑,交付前必须补跑')
1109
1933
  if args.check_v1:
1110
1934
  print('\n--- check_v1 ---')
1935
+ sys.stdout.flush()
1111
1936
  r1 = subprocess.run([sys.executable, args.check_v1,
1112
1937
  os.path.join(pack, 'design.md'), 'slide'])
1113
1938
  rc = rc or r1.returncode
1114
1939
  if rc:
1115
1940
  print('\n门禁未过(exit %d)。回修属 L 层的事:改判断单后重跑本脚本。' % rc)
1941
+ return rc
1116
1942
  return rc
1117
1943
 
1118
1944