@lark-apaas/coding-steering 0.1.18-dev.ec88bb5 → 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.
- package/package.json +1 -1
- package/steering/design-html/skills/charts/SKILL.md +4 -0
- package/steering/design-html/skills/pptx-style-extract/SKILL.md +62 -26
- package/steering/design-html/skills/pptx-style-extract/font-fallback.yaml +3 -3
- package/steering/design-html/skills/pptx-style-extract/scripts/census.py +18 -12
- package/steering/design-html/skills/pptx-style-extract/scripts/check_v2.py +153 -8
- package/steering/design-html/skills/pptx-style-extract/scripts/draft.py +2123 -264
- package/steering/design-html/skills/pptx-style-extract/scripts/extract.py +325 -22
- package/steering/design-html/skills/pptx-style-extract/scripts/ooxml.py +19 -2
- package/steering/design-html/skills/pptx-style-extract/scripts/package.py +504 -160
- package/steering/design-html/skills/pptx-style-extract/scripts/parts.py +6 -3
- package/steering/design-html/skills/pptx-style-extract/scripts/query.py +4 -9
- package/steering/design-html/skills/pptx-style-extract/scripts/render_pages.py +16 -10
- package/steering/design-html/skills/pptx-style-extract/scripts/test_asset_judgment_package.py +161 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/test_background_composite.py +57 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/test_color_contract.py +60 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/test_design_consumer_contract.py +63 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/test_flow_layout_contract.py +468 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/test_layout_css.py +503 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/test_rounded_contract.py +112 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/test_text_role_contract.py +208 -0
- package/steering/design-html/skills/pptx-style-extract/v2-format-spec.md +28 -16
- package/steering/design-html/skills/slide-deck/SKILL.md +15 -20
- package/steering/design-html/skills/slide-deck/scripts/check_local_references.py +179 -0
- package/steering/nestjs-react-fullstack/skills/coding-guide/SKILL.md +10 -0
- package/steering/nestjs-react-fullstack/skills/plugin-guide/SKILL.md +5 -3
- package/steering/nestjs-react-fullstack/skills_local/plugin-guide/SKILL.md +4 -0
- package/steering/vite-react/skills/plugin-guide/SKILL.md +3 -1
- package/steering/vite-react/skills/react-three-fiber/SKILL.md +4 -0
|
@@ -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:
|
|
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 # 双主题包按需
|
|
@@ -80,9 +80,9 @@ L 层判断单 schema(<l-out-dir> 四个文件,这段就是填写说明书
|
|
|
80
80
|
|
|
81
81
|
colors:
|
|
82
82
|
surface: "#FFFFFF"
|
|
83
|
-
primary: "#
|
|
83
|
+
primary: "#RRGGBB"
|
|
84
84
|
safe-area:
|
|
85
|
-
content: {top:
|
|
85
|
+
content: {top: <px>, right: <px>, bottom: <px>, left: <px>, applies-to: [content]}
|
|
86
86
|
confidence: medium
|
|
87
87
|
|
|
88
88
|
--------------------------------------------------------------------------------
|
|
@@ -98,7 +98,8 @@ L 层判断单 schema(<l-out-dir> 四个文件,这段就是填写说明书
|
|
|
98
98
|
role: cover
|
|
99
99
|
background: bg-cover
|
|
100
100
|
slots:
|
|
101
|
-
- {role: title, box: [
|
|
101
|
+
- {role: title, box: [<x>, <y>, <w>, <h>], type: title,
|
|
102
|
+
css: "<由源模板转译出的 CSS 声明串>"}
|
|
102
103
|
confidence: high
|
|
103
104
|
|
|
104
105
|
可选 `body:` 块标量 —— 追加到 layouts.md frontmatter 之后作为说明正文。
|
|
@@ -139,9 +140,11 @@ ASSET_CONSUMER_FIELDS = ['path', 'url', 'color', 'full', 'kind', 'role',
|
|
|
139
140
|
ASSET_AUDIT_FIELDS = ['boxes', 'aspect', 'mark', 'confidence']
|
|
140
141
|
KIND_PREFIX = {'background': 'bg', 'logo': 'logo', 'slogan': 'slogan',
|
|
141
142
|
'texture': 'texture', 'icon': 'icon'}
|
|
142
|
-
#
|
|
143
|
-
|
|
144
|
-
|
|
143
|
+
# 交付包的 ref/ 只留 audit.yaml(人复核数值出处用,几 KB)。
|
|
144
|
+
# 频次原表、聚类原始数据、extract.json、重建图、logo 候选图全部留在 stage1 抽取目录,
|
|
145
|
+
# 不拷进包——规范原本指望「下发时链路剥离」,但链路上没有环节真的剥离,结果
|
|
146
|
+
# 审计材料带着「别读我」一起进消费上下文,还占掉包体的大头。
|
|
147
|
+
REF_CARRY = ()
|
|
145
148
|
|
|
146
149
|
|
|
147
150
|
class Fail(SystemExit):
|
|
@@ -232,11 +235,13 @@ def parse_item_list(lines):
|
|
|
232
235
|
|
|
233
236
|
def read_manifest(path):
|
|
234
237
|
blocks = split_top_blocks(open(path, encoding='utf-8').read())
|
|
235
|
-
man, assets, raw = {}, [], {}
|
|
238
|
+
man, assets, asset_decisions, raw = {}, [], [], {}
|
|
236
239
|
derived = []
|
|
237
240
|
for key, inline, lines in blocks:
|
|
238
241
|
if key == 'assets':
|
|
239
242
|
assets = parse_item_list(lines)
|
|
243
|
+
elif key == 'asset_decisions':
|
|
244
|
+
asset_decisions = parse_item_list(lines)
|
|
240
245
|
elif key == 'derived':
|
|
241
246
|
derived = parse_item_list(lines)
|
|
242
247
|
elif inline.startswith('['):
|
|
@@ -250,6 +255,7 @@ def read_manifest(path):
|
|
|
250
255
|
# 不把作者的折行改成一条长行。
|
|
251
256
|
raw[key] = (inline, lines)
|
|
252
257
|
man['assets'] = assets
|
|
258
|
+
man['asset_decisions'] = asset_decisions
|
|
253
259
|
man['derived'] = derived
|
|
254
260
|
man['_raw'] = raw
|
|
255
261
|
return man
|
|
@@ -272,6 +278,10 @@ def truthy(v):
|
|
|
272
278
|
return str(v).strip().lower() in ('1', 'true', 'yes', 'on')
|
|
273
279
|
|
|
274
280
|
|
|
281
|
+
# 浏览器能解码的图片格式。落进包的资产必须在此列,否则消费端引用到就是一张空白图。
|
|
282
|
+
WEB_SAFE_EXT = {'png', 'jpg', 'jpeg', 'webp', 'gif', 'svg', 'avif'}
|
|
283
|
+
|
|
284
|
+
|
|
275
285
|
def asset_ext(name):
|
|
276
286
|
return name.rsplit('.', 1)[-1].lower() if '.' in name else ''
|
|
277
287
|
|
|
@@ -284,6 +294,68 @@ def strip_kind_prefix(aid, kind):
|
|
|
284
294
|
return aid
|
|
285
295
|
|
|
286
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
|
+
|
|
287
359
|
def media_row_of(extract, source_media):
|
|
288
360
|
for m in extract.get('media') or []:
|
|
289
361
|
if m.get('out') and os.path.basename(m['out']) == source_media:
|
|
@@ -351,12 +423,25 @@ def place_assets(manifest, extract, stage1, pack):
|
|
|
351
423
|
entry['path'] = 'assets/%ss/%s.%s' % (kind, base, cext)
|
|
352
424
|
if truthy(a.get('use_full')):
|
|
353
425
|
oext = asset_ext(row['out'])
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
426
|
+
if oext.lower() in WEB_SAFE_EXT:
|
|
427
|
+
fdst = os.path.join(sub, '%s@full.%s' % (base, oext))
|
|
428
|
+
shutil.copy2(orig_path, fdst)
|
|
429
|
+
copied.append(fdst)
|
|
430
|
+
entry['full'] = 'assets/%ss/%s@full.%s' % (kind, base, oext)
|
|
431
|
+
else:
|
|
432
|
+
# 原图是浏览器不解码的格式(tiff/bmp 之类)。落进包并在 design.md
|
|
433
|
+
# 里声明成可用资源,消费端引用它就是一张空白图——实测踩过一次,
|
|
434
|
+
# 封面与结束页因此空白。压缩版已经带着全部像素,full 不给。
|
|
435
|
+
print(' ⚠ %s 的原图是 .%s,浏览器不解码,只给压缩版' % (aid, oext))
|
|
358
436
|
else:
|
|
359
437
|
oext = asset_ext(row['out'])
|
|
438
|
+
if oext.lower() not in WEB_SAFE_EXT:
|
|
439
|
+
# 转码两条路(Pillow / sips)都没成,原图又是浏览器不解码的格式。
|
|
440
|
+
# 照落进去就是把一张永远显示不出来的图当资产下发——实测封面因此空白。
|
|
441
|
+
raise Fail('%s: 原图是 .%s,浏览器不解码,而转码没有产物'
|
|
442
|
+
'(extract.json 里看 transcode_blocked 的原因)。'
|
|
443
|
+
'装上 Pillow 重跑抽取,或把这条资产从 manifest 删掉'
|
|
444
|
+
'并在 gaps 写明。' % (aid, oext))
|
|
360
445
|
dst = os.path.join(sub, '%s.%s' % (base, oext))
|
|
361
446
|
shutil.copy2(orig_path, dst)
|
|
362
447
|
copied.append(dst)
|
|
@@ -382,7 +467,7 @@ def place_assets(manifest, extract, stage1, pack):
|
|
|
382
467
|
if boxes:
|
|
383
468
|
au['boxes'] = boxes
|
|
384
469
|
# aspect 取**未取整**的 box —— 从取整后的整数反算会明显偏
|
|
385
|
-
#
|
|
470
|
+
# 先取整再相除,误差会落到小数点后两位,logo 这种细长框尤其明显。
|
|
386
471
|
raw = clusters[0]['box']
|
|
387
472
|
if raw.get('h'):
|
|
388
473
|
au['aspect'] = round(raw['w'] / float(raw['h']), 3)
|
|
@@ -406,6 +491,27 @@ def _pages_of(extract, source_media):
|
|
|
406
491
|
return sorted(pages)
|
|
407
492
|
|
|
408
493
|
|
|
494
|
+
def _archetypes_using(layouts_text, aid, key='asset'):
|
|
495
|
+
"""layouts.yaml 里哪些 archetype 引用了这个资产。
|
|
496
|
+
|
|
497
|
+
key='asset' 查 slots 里的图片槽;key='background' 查页型底图。
|
|
498
|
+
"""
|
|
499
|
+
out, cur, sect = [], None, None
|
|
500
|
+
for line in (layouts_text or '').split('\n'):
|
|
501
|
+
if re.match(r'^\w[\w-]*:\s*$', line):
|
|
502
|
+
sect = line.split(':')[0]
|
|
503
|
+
continue
|
|
504
|
+
if sect != 'layouts':
|
|
505
|
+
continue
|
|
506
|
+
m = re.match(r'^ ([\w-]+):\s*$', line)
|
|
507
|
+
if m:
|
|
508
|
+
cur = m.group(1)
|
|
509
|
+
continue
|
|
510
|
+
if cur and re.search(r'%s:\s*%s\b' % (key, re.escape(aid)), line) and cur not in out:
|
|
511
|
+
out.append(cur)
|
|
512
|
+
return out
|
|
513
|
+
|
|
514
|
+
|
|
409
515
|
def expand_placeholders(body, manifest, consumer, audit, extract, layouts_text):
|
|
410
516
|
"""body.md 里的 `{{ASSET_TABLE}}` / `{{LAYOUT_LIST}}` 由本脚本按落盘真值渲染——
|
|
411
517
|
路径与页型清单是打包期才确定的事实,不该由 L 层手抄(抄错就是死链)。"""
|
|
@@ -415,24 +521,35 @@ def expand_placeholders(body, manifest, consumer, audit, extract, layouts_text):
|
|
|
415
521
|
for aid, e in consumer.items():
|
|
416
522
|
pages = _pages_of(extract, src_of.get(aid))
|
|
417
523
|
box = (audit.get(aid) or {}).get('boxes') or []
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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 ',每页固定放一次')
|
|
524
|
+
# 单元格保持短句:哪个页型用哪张由 `background` 字段决定,
|
|
525
|
+
# 那句说明放在表格前一次即可,逐行重复只是把同一句抄 N 遍。
|
|
526
|
+
if e['kind'] == 'background':
|
|
527
|
+
users = _archetypes_using(layouts_text, aid, key='background')
|
|
528
|
+
when = ('%s 用它' % '/'.join('`%s`' % o for o in users[:6])
|
|
529
|
+
if users else '未被任何页型引用')
|
|
428
530
|
else:
|
|
429
|
-
|
|
531
|
+
# 位置逐页型不同(同一 logo 常在封面左上、内容页右上,尺寸也不同),
|
|
532
|
+
# 所以这里只给归属,坐标一律交给 layouts.md 的 slots。
|
|
533
|
+
owners = _archetypes_using(layouts_text, aid)
|
|
534
|
+
when = ('%s 这 %d 个页型带它' % ('/'.join('`%s`' % o for o in owners[:6]),
|
|
535
|
+
len(owners))
|
|
536
|
+
if owners else '⚠ 没有页型声明它的位置,本包不使用它')
|
|
430
537
|
f = '`%s`' % e['path'] if e.get('path') else (
|
|
431
538
|
'`%s`' % e['url'] if e.get('url') else '纯色 `%s`' % e.get('color'))
|
|
432
539
|
if e.get('full'):
|
|
433
540
|
f += '(原图 `%s`)' % e['full']
|
|
434
541
|
rows.append('| `%s` | %s | %s |' % (aid, f, when))
|
|
435
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))
|
|
436
553
|
if '{{LAYOUT_LIST}}' in body:
|
|
437
554
|
rows, sect, names = [], None, {}
|
|
438
555
|
for line in (layouts_text or '').split('\n'):
|
|
@@ -466,7 +583,7 @@ def render_assets_block(consumer):
|
|
|
466
583
|
|
|
467
584
|
# ------------------------------------------------- 数值可追溯机检(抄错即 FAIL)
|
|
468
585
|
HEX_RE = re.compile(r'#([0-9A-Fa-f]{6})\b')
|
|
469
|
-
FONTSIZE_RE = re.compile(r'\
|
|
586
|
+
FONTSIZE_RE = re.compile(r'\b(?:fontSize|font-size):\s*([\d.]+)px')
|
|
470
587
|
BOX_RE = re.compile(r'\bbox:\s*\[\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*,'
|
|
471
588
|
r'\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*\]')
|
|
472
589
|
KEYLINE_RE = re.compile(r'^(\s*)-?\s*([A-Za-z_][\w-]*):\s*(.*)$')
|
|
@@ -604,6 +721,47 @@ def _box_hit(box, idx):
|
|
|
604
721
|
% (best[0], best[1], best[2], best[3], best[4], bestd))
|
|
605
722
|
|
|
606
723
|
|
|
724
|
+
# 判断产物:L 层看着背景图划出来的区域,本就不该命中普查值——整键豁免,
|
|
725
|
+
# 不要求写 derived。逼它们走 derived 只会把整段豁免掉,机检反而更弱。
|
|
726
|
+
JUDGEMENT_KEYS = frozenset(('avoid', 'text_safe', 'pairing_rule'))
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def media_integrity(stage1, extract):
|
|
730
|
+
"""extract.json 记录的尺寸,必须和磁盘上那个文件真实的尺寸一致。
|
|
731
|
+
|
|
732
|
+
这条挡的是「记录全对、文件被顶替」——两个 media 的输出名撞车时,后写的覆盖先写的,
|
|
733
|
+
extract.json 里各自的记录都还是对的,任何只读记录的检查都发现不了;只有把记录和
|
|
734
|
+
文件本身对一遍才抓得到。实测某模板因此把一张 109x109 的图当成了整页背景。
|
|
735
|
+
"""
|
|
736
|
+
try:
|
|
737
|
+
from PIL import Image
|
|
738
|
+
except Exception:
|
|
739
|
+
return [] # 没有 Pillow 就量不了,跳过而不是假装通过
|
|
740
|
+
bad, seen = [], {}
|
|
741
|
+
for m in extract.get('media') or []:
|
|
742
|
+
for key, rel in (('source_px', m.get('out')),
|
|
743
|
+
('compressed_px', m.get('compressed_out'))):
|
|
744
|
+
rec = m.get(key)
|
|
745
|
+
if not rel or not rec:
|
|
746
|
+
continue
|
|
747
|
+
path = os.path.join(stage1, rel)
|
|
748
|
+
if not os.path.exists(path):
|
|
749
|
+
bad.append('%s 的 %s 指向 %s,文件不存在' % (m['media'], key, rel))
|
|
750
|
+
continue
|
|
751
|
+
try:
|
|
752
|
+
with Image.open(path) as im:
|
|
753
|
+
real = list(im.size)
|
|
754
|
+
except Exception:
|
|
755
|
+
continue
|
|
756
|
+
if real != list(rec):
|
|
757
|
+
bad.append('%s 的 %s 记录 %s,但 %s 实际是 %s'
|
|
758
|
+
% (m['media'], key, rec, rel, real))
|
|
759
|
+
if rel in seen and seen[rel] != m['media']:
|
|
760
|
+
bad.append('%s 与 %s 都写到 %s' % (seen[rel], m['media'], rel))
|
|
761
|
+
seen[rel] = m['media']
|
|
762
|
+
return bad
|
|
763
|
+
|
|
764
|
+
|
|
607
765
|
def trace_check(pack, extract, shapes, derived_values, derived_tokens, factor):
|
|
608
766
|
"""产物里每个 hex / 字号 / slot 坐标都必须可追溯到普查值或 derived 声明。"""
|
|
609
767
|
idx = build_trace_index(extract, shapes)
|
|
@@ -614,6 +772,9 @@ def trace_check(pack, extract, shapes, derived_values, derived_tokens, factor):
|
|
|
614
772
|
if not text:
|
|
615
773
|
continue
|
|
616
774
|
for lineno, path, raw in _scan_keyed(text):
|
|
775
|
+
if JUDGEMENT_KEYS & set(path):
|
|
776
|
+
checked['judgement'] += 1
|
|
777
|
+
continue
|
|
617
778
|
exempt_token = any(k in derived_tokens for k in path)
|
|
618
779
|
for m in HEX_RE.finditer(raw):
|
|
619
780
|
val = '#' + m.group(1).upper()
|
|
@@ -762,106 +923,6 @@ def build_package_manifest(manifest, consumer, audit, extract, pack):
|
|
|
762
923
|
return {k: v for k, v in pkg.items() if v is not None}
|
|
763
924
|
|
|
764
925
|
|
|
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
926
|
def strip_agent_fast_path(body):
|
|
866
927
|
if '## Agent Fast Path' not in body:
|
|
867
928
|
return body
|
|
@@ -869,8 +930,7 @@ def strip_agent_fast_path(body):
|
|
|
869
930
|
count=1, flags=re.S).lstrip('\n')
|
|
870
931
|
|
|
871
932
|
|
|
872
|
-
def build_design(manifest, l_frontmatter, consumer, body, has_sidecar, canvas
|
|
873
|
-
layouts_index=None):
|
|
933
|
+
def build_design(manifest, l_frontmatter, consumer, body, has_sidecar, canvas):
|
|
874
934
|
blocks = {k: (inline, lines) for k, inline, lines in l_frontmatter}
|
|
875
935
|
bad = set(blocks) - L_FRONTMATTER_KEYS
|
|
876
936
|
if bad:
|
|
@@ -908,9 +968,9 @@ def build_design(manifest, l_frontmatter, consumer, body, has_sidecar, canvas,
|
|
|
908
968
|
out.append('canvas: %dx%d' % tuple(canvas))
|
|
909
969
|
out.append('---')
|
|
910
970
|
text = '\n'.join(out) + '\n'
|
|
971
|
+
# Fast Path 已废弃:它是 Usage / Hard Rules / 资产表 / 页型清单的第二份副本。
|
|
972
|
+
# 规则的唯一出处是正文各段,这里只负责把历史产物里的残留剥掉。
|
|
911
973
|
body = strip_agent_fast_path(body)
|
|
912
|
-
text += '\n' + render_fast_path(manifest, consumer, has_sidecar, canvas,
|
|
913
|
-
layouts_index=layouts_index)
|
|
914
974
|
if body:
|
|
915
975
|
text += body if body.startswith('\n') else '\n' + body
|
|
916
976
|
if not text.endswith('\n'):
|
|
@@ -918,26 +978,292 @@ def build_design(manifest, l_frontmatter, consumer, body, has_sidecar, canvas,
|
|
|
918
978
|
return text
|
|
919
979
|
|
|
920
980
|
|
|
921
|
-
def
|
|
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
|
+
|
|
1039
|
+
def shrink_safe_area(lines):
|
|
1040
|
+
"""text_safe 与 avoid 相交时把安全区收掉重叠的那部分。
|
|
1041
|
+
|
|
1042
|
+
text_safe 是脚本按「该背景各页型的槽位并集」算的,而模板里的槽位本身可能就压在
|
|
1043
|
+
主视觉上;avoid 是看图的人填的。两者从不同来源来,会直接打架——实测一张背景的
|
|
1044
|
+
text_safe 有 34% 落在 avoid 里,消费端按 text_safe 把标题放进了禁放区,正好压在
|
|
1045
|
+
背景的山峰主体上。这里以 avoid 为准收缩:能不能放字是看图的人说了算。
|
|
1046
|
+
"""
|
|
1047
|
+
def parse_box(s):
|
|
1048
|
+
m = re.findall(r'-?\d+', s)
|
|
1049
|
+
return [int(x) for x in m[:4]] if len(m) >= 4 else None
|
|
1050
|
+
|
|
1051
|
+
ts_i = ts = None
|
|
1052
|
+
avoids = []
|
|
1053
|
+
for i, ln in enumerate(lines):
|
|
1054
|
+
st = ln.strip()
|
|
1055
|
+
if st.startswith('text_safe:'):
|
|
1056
|
+
ts_i, ts = i, parse_box(st.split(':', 1)[1].split('#')[0])
|
|
1057
|
+
elif st.startswith('avoid:'):
|
|
1058
|
+
for chunk in re.findall(r'box:\s*\[[^\]]*\]', st):
|
|
1059
|
+
b = parse_box(chunk)
|
|
1060
|
+
if b:
|
|
1061
|
+
avoids.append(b)
|
|
1062
|
+
if ts is None or not avoids:
|
|
1063
|
+
return lines, None
|
|
1064
|
+
x0, y0, x1, y1 = ts[0], ts[1], ts[0] + ts[2], ts[1] + ts[3]
|
|
1065
|
+
for a in avoids:
|
|
1066
|
+
ax0, ay0, ax1, ay1 = a[0], a[1], a[0] + a[2], a[1] + a[3]
|
|
1067
|
+
if ax1 <= x0 or ax0 >= x1 or ay1 <= y0 or ay0 >= y1:
|
|
1068
|
+
continue # 不相交
|
|
1069
|
+
# 从被侵占得最少的一边切:优先保留面积最大的剩余矩形
|
|
1070
|
+
cands = []
|
|
1071
|
+
if ax0 > x0:
|
|
1072
|
+
cands.append((x0, y0, ax0, y1))
|
|
1073
|
+
if ax1 < x1:
|
|
1074
|
+
cands.append((ax1, y0, x1, y1))
|
|
1075
|
+
if ay0 > y0:
|
|
1076
|
+
cands.append((x0, y0, x1, ay0))
|
|
1077
|
+
if ay1 < y1:
|
|
1078
|
+
cands.append((x0, ay1, x1, y1))
|
|
1079
|
+
if not cands:
|
|
1080
|
+
return lines, 'text_safe 被 avoid 完全覆盖,这张背景没有可放文字的区域'
|
|
1081
|
+
x0, y0, x1, y1 = max(cands, key=lambda c: (c[2] - c[0]) * (c[3] - c[1]))
|
|
1082
|
+
new = [x0, y0, x1 - x0, y1 - y0]
|
|
1083
|
+
if new == ts:
|
|
1084
|
+
return lines, None
|
|
1085
|
+
lines = list(lines)
|
|
1086
|
+
lines[ts_i] = ' text_safe: [%d, %d, %d, %d] # 已按 avoid 收缩(原 %s)' % (
|
|
1087
|
+
new[0], new[1], new[2], new[3], ts)
|
|
1088
|
+
return lines, None
|
|
1089
|
+
|
|
1090
|
+
|
|
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):
|
|
922
1125
|
blocks = {k: (inline, lines) for k, inline, lines in layouts_blocks}
|
|
923
1126
|
if 'canvas' in blocks:
|
|
924
1127
|
raise Fail('layouts.yaml 不要写 canvas —— 脚本从 extract.json 取')
|
|
925
1128
|
if 'layouts' not in blocks:
|
|
926
1129
|
raise Fail('layouts.yaml 缺顶层键 `layouts:`')
|
|
927
|
-
# `names:` 是给 L
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
1130
|
+
# `names:` / `roles:` / `text_roles:` / `layout_modes:` / `bg_rules:` 是给 L 层集中填判断的
|
|
1131
|
+
# 扁平区——在这里并回各 archetype,本身不进产物。让 L 层只改扁平键值,别去动 layouts 里的
|
|
1132
|
+
# slots/confidence 结构(嵌套结构手改极易破坏缩进,进而静默改变语义)。
|
|
1133
|
+
names, roles = {}, {}
|
|
1134
|
+
for key, sink in (('names', names), ('roles', roles)):
|
|
1135
|
+
for line in blocks.get(key, ('', []))[1]:
|
|
1136
|
+
m = re.match(r'^\s{2}([\w-]+):\s*(.+?)\s*$', line)
|
|
1137
|
+
if m:
|
|
1138
|
+
sink[m.group(1)] = unquote(m.group(2))
|
|
1139
|
+
text_roles = {}
|
|
1140
|
+
allowed_text_roles = {'title', 'subtitle', 'header', 'footer', 'body'}
|
|
1141
|
+
for line in blocks.get('text_roles', ('', []))[1]:
|
|
1142
|
+
m = re.match(r'^\s{2}([\w-]+):\s*([A-Za-z-]+)(?:\s+#.*)?$', line)
|
|
1143
|
+
if not m:
|
|
1144
|
+
continue
|
|
1145
|
+
role_id, role = m.groups()
|
|
1146
|
+
if role not in allowed_text_roles:
|
|
1147
|
+
raise Fail('text_roles.%s 取值 %s 非法;应为 %s'
|
|
1148
|
+
% (role_id, role, '|'.join(sorted(allowed_text_roles))))
|
|
1149
|
+
text_roles[role_id] = role
|
|
1150
|
+
bg_rules, cur = {}, None
|
|
1151
|
+
for line in blocks.get('bg_rules', ('', []))[1]:
|
|
1152
|
+
# 键后面允许行内注释(草案会标「用它的页型:…」)
|
|
1153
|
+
m = re.match(r'^\s{2}([\w-]+):\s*(?:#.*)?$', line)
|
|
931
1154
|
if m:
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
1155
|
+
cur = m.group(1)
|
|
1156
|
+
bg_rules[cur] = []
|
|
1157
|
+
continue
|
|
1158
|
+
if cur and line.strip():
|
|
1159
|
+
bg_rules[cur].append(' ' + line.strip())
|
|
1160
|
+
out = ['---', 'canvas: %dx%d' % tuple(canvas)]
|
|
1161
|
+
# 禁放区是背景的属性,按背景写一次;页型只留 `background:` 指针。
|
|
1162
|
+
# 合并进每个页型会把同一句话按页型数复制 N 遍。
|
|
1163
|
+
if bg_rules:
|
|
1164
|
+
out.append('backgrounds:')
|
|
1165
|
+
for bg, lines in bg_rules.items():
|
|
1166
|
+
lines, err = shrink_safe_area(lines)
|
|
1167
|
+
if err:
|
|
1168
|
+
raise Fail('%s: %s' % (bg, err))
|
|
1169
|
+
out.append(' %s:' % bg)
|
|
1170
|
+
out += lines
|
|
1171
|
+
modes = layout_modes(blocks.get('layout_modes', ('', []))[1])
|
|
1172
|
+
out.append('layouts:')
|
|
1173
|
+
pending_text_role = None
|
|
1174
|
+
used_text_roles = set()
|
|
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:
|
|
936
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()
|
|
1196
|
+
# 判断单里的结构事实(栅格、间距序列、样张字数、命中配方)是给 L 层判断用的,
|
|
1197
|
+
# 不进产物——消费端要的是结论,不是推导过程。layout_mode 同理,它是判断的载体。
|
|
1198
|
+
if line.lstrip().startswith('#'):
|
|
1199
|
+
marker = re.match(r'^\s*#\s*text-role:\s*([\w-]+)\s*$', line)
|
|
1200
|
+
if marker:
|
|
1201
|
+
pending_text_role = marker.group(1)
|
|
1202
|
+
continue
|
|
1203
|
+
if pending_text_role:
|
|
1204
|
+
if pending_text_role not in text_roles:
|
|
1205
|
+
raise Fail('text_roles 缺少 %s 的判断' % pending_text_role)
|
|
1206
|
+
role = text_roles[pending_text_role]
|
|
1207
|
+
slot_type = role if role in ('title', 'subtitle', 'header', 'footer') else 'body'
|
|
1208
|
+
line, role_n = re.subn(r'(\{\s*role:\s*)[\w-]+',
|
|
1209
|
+
r'\g<1>%s' % role, line, count=1)
|
|
1210
|
+
line, type_n = re.subn(r'(\btype:\s*)[\w-]+',
|
|
1211
|
+
r'\g<1>%s' % slot_type, line, count=1)
|
|
1212
|
+
if role_n != 1 or type_n != 1:
|
|
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
|
|
1225
|
+
used_text_roles.add(pending_text_role)
|
|
1226
|
+
pending_text_role = None
|
|
1227
|
+
out.append(line)
|
|
937
1228
|
if m and m.group(1) in names:
|
|
938
1229
|
out.append(' name: "%s"' % names.pop(m.group(1)))
|
|
1230
|
+
if m and m.group(1) in roles:
|
|
1231
|
+
out.append(' role: %s' % roles.pop(m.group(1)))
|
|
1232
|
+
flush_fixed_items()
|
|
939
1233
|
if names:
|
|
940
1234
|
raise Fail('names 里这些页型在 layouts 下找不到:%s' % ', '.join(sorted(names)))
|
|
1235
|
+
if roles:
|
|
1236
|
+
raise Fail('roles 里这些页型在 layouts 下找不到:%s' % ', '.join(sorted(roles)))
|
|
1237
|
+
unused_text_roles = set(text_roles) - used_text_roles
|
|
1238
|
+
if unused_text_roles:
|
|
1239
|
+
raise Fail('text_roles 里这些判断没有命中文本槽:%s'
|
|
1240
|
+
% ', '.join(sorted(unused_text_roles)))
|
|
1241
|
+
missing_role = [k for k in re.findall(r'^ ([\w-]+):\s*$', '\n'.join(blocks['layouts'][1]), re.M)
|
|
1242
|
+
if not re.search(r'^ %s:\s*$(?:\n(?! \S).*)*?\n role:' % re.escape(k),
|
|
1243
|
+
'\n'.join(out), re.M)]
|
|
1244
|
+
if missing_role:
|
|
1245
|
+
raise Fail('这些页型没有 role(在 layouts.yaml 的 roles 段填):%s' % ', '.join(missing_role))
|
|
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))
|
|
1262
|
+
declared = set(re.findall(r'^ background:\s*(\S+)\s*$',
|
|
1263
|
+
'\n'.join(blocks['layouts'][1]), re.M))
|
|
1264
|
+
stray = set(bg_rules) - declared
|
|
1265
|
+
if stray:
|
|
1266
|
+
raise Fail('backgrounds 段里这些背景没有任何页型在用:%s' % ', '.join(sorted(stray)))
|
|
941
1267
|
out.append('---')
|
|
942
1268
|
text = '\n'.join(out) + '\n'
|
|
943
1269
|
if 'body' in blocks:
|
|
@@ -993,6 +1319,12 @@ def main(argv=None):
|
|
|
993
1319
|
if left:
|
|
994
1320
|
raise Fail('判断单还有 %d 处草案占位没改(TODO 是 draft.py 留给 L 层的判断点):\n %s'
|
|
995
1321
|
% (len(left), '\n '.join(left)))
|
|
1322
|
+
mdesc = re.search(r'^description:\s*[|>]?\s*\n((?:\s+\S.*\n?)+)',
|
|
1323
|
+
open(os.path.join(lout, 'manifest.yaml'), encoding='utf-8').read(), re.M)
|
|
1324
|
+
if mdesc and len(re.sub(r'\s+', '', mdesc.group(1))) < 20:
|
|
1325
|
+
raise Fail('manifest 的 description 只有 %d 个字符——它是消费模型定调的唯一入口,'
|
|
1326
|
+
'写清底色/主色/字形/版面骨架,不要留占位'
|
|
1327
|
+
% len(re.sub(r'\s+', '', mdesc.group(1))))
|
|
996
1328
|
|
|
997
1329
|
manifest = read_manifest(os.path.join(lout, 'manifest.yaml'))
|
|
998
1330
|
if args.style_name:
|
|
@@ -1009,41 +1341,42 @@ def main(argv=None):
|
|
|
1009
1341
|
lay_path = os.path.join(lout, 'layouts.yaml')
|
|
1010
1342
|
layouts_blocks = (split_top_blocks(open(lay_path, encoding='utf-8').read())
|
|
1011
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)
|
|
1012
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)
|
|
1013
1357
|
|
|
1014
1358
|
os.makedirs(pack, exist_ok=True)
|
|
1015
1359
|
consumer, audit, copied = place_assets(manifest, extract, stage1, pack)
|
|
1016
1360
|
|
|
1017
|
-
lay_text =
|
|
1361
|
+
lay_text = final_layouts or ''
|
|
1018
1362
|
body = expand_placeholders(body, manifest, consumer, audit, extract, lay_text)
|
|
1019
|
-
layouts_index = layout_fast_index(layouts_blocks)
|
|
1020
1363
|
design = build_design(manifest, l_fm, consumer, body,
|
|
1021
|
-
has_sidecar=layouts_blocks is not None, canvas=canvas
|
|
1022
|
-
layouts_index=layouts_index)
|
|
1364
|
+
has_sidecar=layouts_blocks is not None, canvas=canvas)
|
|
1023
1365
|
with open(os.path.join(pack, 'design.md'), 'w', encoding='utf-8') as f:
|
|
1024
1366
|
f.write(design)
|
|
1025
1367
|
if layouts_blocks is not None:
|
|
1026
1368
|
with open(os.path.join(pack, 'layouts.md'), 'w', encoding='utf-8') as f:
|
|
1027
|
-
f.write(
|
|
1369
|
+
f.write(final_layouts)
|
|
1028
1370
|
|
|
1029
|
-
ref
|
|
1371
|
+
# 审计记录写回**抽取工作目录**,不进交付包:包是消费产物,`ref/` 里的东西
|
|
1372
|
+
# 没有任何门禁读、消费模型也用不上,放进去只会让「别读我」和「几 MB 材料」同时下发。
|
|
1373
|
+
ref = os.path.join(stage1, 'ref')
|
|
1030
1374
|
os.makedirs(ref, exist_ok=True)
|
|
1031
|
-
|
|
1375
|
+
audit_path = os.path.join(ref, 'audit.yaml')
|
|
1376
|
+
with open(audit_path, 'w', encoding='utf-8') as f:
|
|
1032
1377
|
f.write(render_audit(manifest, extract, audit))
|
|
1033
1378
|
carried = []
|
|
1034
|
-
|
|
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 补充件(判断理由、版式溯源等)原样带入
|
|
1379
|
+
# L 层自备的 ref 补充件(判断理由、版式溯源等)也归到抽取目录
|
|
1047
1380
|
l_ref = os.path.join(lout, 'ref')
|
|
1048
1381
|
if os.path.isdir(l_ref):
|
|
1049
1382
|
shutil.copytree(l_ref, ref, dirs_exist_ok=True)
|
|
@@ -1061,7 +1394,9 @@ def main(argv=None):
|
|
|
1061
1394
|
print(' assets %d 条目 / %d 文件落盘' % (len(consumer), len(copied)))
|
|
1062
1395
|
for d in copied:
|
|
1063
1396
|
print(' %-52s %8d B' % (os.path.relpath(d, pack), os.path.getsize(d)))
|
|
1064
|
-
print('
|
|
1397
|
+
print(' 审计记录 -> %s%s(不进交付包)'
|
|
1398
|
+
% (os.path.relpath(audit_path, os.path.dirname(stage1)),
|
|
1399
|
+
' ' + ', '.join(carried) if carried else ''))
|
|
1065
1400
|
|
|
1066
1401
|
# ---- 数值可追溯机检:产物里每个色值/字号/坐标都得能指回普查值
|
|
1067
1402
|
derived_values, derived_tokens = set(), set()
|
|
@@ -1079,9 +1414,17 @@ def main(argv=None):
|
|
|
1079
1414
|
shapes = json.load(open(sp, encoding='utf-8'))['shapes'] if os.path.exists(sp) else []
|
|
1080
1415
|
problems, checked = trace_check(pack, extract, shapes,
|
|
1081
1416
|
derived_values, derived_tokens, factor)
|
|
1417
|
+
integrity = media_integrity(stage1, extract)
|
|
1418
|
+
if integrity:
|
|
1419
|
+
print('\n--- 媒体产物完整性 ---')
|
|
1420
|
+
for b in integrity:
|
|
1421
|
+
print(' FAIL ' + b)
|
|
1422
|
+
raise Fail('media-out 里的文件和 extract.json 的记录对不上(%d 处)' % len(integrity))
|
|
1082
1423
|
print('\n--- 数值可追溯机检 ---')
|
|
1083
|
-
print(' 受检 hex %d / fontSize %d / slot box %d;derived 豁免 %d%s'
|
|
1424
|
+
print(' 受检 hex %d / fontSize %d / slot box %d;derived 豁免 %d%s%s'
|
|
1084
1425
|
% (checked['hex'], checked['size'], checked['box'], checked['exempt'],
|
|
1426
|
+
';判断键跳过 %d 行(avoid/text_safe/pairing_rule)' % checked['judgement']
|
|
1427
|
+
if checked['judgement'] else '',
|
|
1085
1428
|
';rebase_factor=%g' % factor if factor else ''))
|
|
1086
1429
|
if not shapes:
|
|
1087
1430
|
print(' ⚠ 未找到 %s,slot 坐标一项无法校验' % sp)
|
|
@@ -1108,6 +1451,7 @@ def main(argv=None):
|
|
|
1108
1451
|
print('check_v1: 未探测到($DSM_V1_DIR / 同级 skill / 开发机路径均无)—— v1 门禁未跑,交付前必须补跑')
|
|
1109
1452
|
if args.check_v1:
|
|
1110
1453
|
print('\n--- check_v1 ---')
|
|
1454
|
+
sys.stdout.flush()
|
|
1111
1455
|
r1 = subprocess.run([sys.executable, args.check_v1,
|
|
1112
1456
|
os.path.join(pack, 'design.md'), 'slide'])
|
|
1113
1457
|
rc = rc or r1.returncode
|