@lark-apaas/coding-steering 0.1.18-dev.655b398 → 0.1.18-dev.7f786ca

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 (25) hide show
  1. package/package.json +1 -1
  2. package/steering/design-html/skills/charts/SKILL.md +4 -0
  3. package/steering/design-html/skills/pptx-style-extract/SKILL.md +44 -22
  4. package/steering/design-html/skills/pptx-style-extract/scripts/check_v2.py +140 -2
  5. package/steering/design-html/skills/pptx-style-extract/scripts/draft.py +1276 -177
  6. package/steering/design-html/skills/pptx-style-extract/scripts/extract.py +277 -15
  7. package/steering/design-html/skills/pptx-style-extract/scripts/ooxml.py +18 -1
  8. package/steering/design-html/skills/pptx-style-extract/scripts/package.py +392 -14
  9. package/steering/design-html/skills/pptx-style-extract/scripts/parts.py +3 -0
  10. package/steering/design-html/skills/pptx-style-extract/scripts/query.py +3 -8
  11. package/steering/design-html/skills/pptx-style-extract/scripts/test_asset_judgment_package.py +161 -0
  12. package/steering/design-html/skills/pptx-style-extract/scripts/test_background_composite.py +57 -0
  13. package/steering/design-html/skills/pptx-style-extract/scripts/test_color_contract.py +60 -0
  14. package/steering/design-html/skills/pptx-style-extract/scripts/test_design_consumer_contract.py +63 -0
  15. package/steering/design-html/skills/pptx-style-extract/scripts/test_flow_layout_contract.py +468 -0
  16. package/steering/design-html/skills/pptx-style-extract/scripts/test_layout_css.py +503 -0
  17. package/steering/design-html/skills/pptx-style-extract/scripts/test_rounded_contract.py +112 -0
  18. package/steering/design-html/skills/pptx-style-extract/scripts/test_text_role_contract.py +208 -0
  19. package/steering/design-html/skills/pptx-style-extract/v2-format-spec.md +16 -7
  20. package/steering/design-html/skills/slide-deck/SKILL.md +15 -20
  21. package/steering/design-html/skills/slide-deck/scripts/check_local_references.py +179 -0
  22. package/steering/nestjs-react-fullstack/skills/plugin-guide/SKILL.md +5 -3
  23. package/steering/nestjs-react-fullstack/skills_local/plugin-guide/SKILL.md +4 -0
  24. package/steering/vite-react/skills/plugin-guide/SKILL.md +3 -1
  25. package/steering/vite-react/skills/react-three-fiber/SKILL.md +4 -0
@@ -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: [<x>, <y>, <w>, <h>], type: title}
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 之后作为说明正文。
@@ -234,11 +235,13 @@ def parse_item_list(lines):
234
235
 
235
236
  def read_manifest(path):
236
237
  blocks = split_top_blocks(open(path, encoding='utf-8').read())
237
- man, assets, raw = {}, [], {}
238
+ man, assets, asset_decisions, raw = {}, [], [], {}
238
239
  derived = []
239
240
  for key, inline, lines in blocks:
240
241
  if key == 'assets':
241
242
  assets = parse_item_list(lines)
243
+ elif key == 'asset_decisions':
244
+ asset_decisions = parse_item_list(lines)
242
245
  elif key == 'derived':
243
246
  derived = parse_item_list(lines)
244
247
  elif inline.startswith('['):
@@ -252,6 +255,7 @@ def read_manifest(path):
252
255
  # 不把作者的折行改成一条长行。
253
256
  raw[key] = (inline, lines)
254
257
  man['assets'] = assets
258
+ man['asset_decisions'] = asset_decisions
255
259
  man['derived'] = derived
256
260
  man['_raw'] = raw
257
261
  return man
@@ -274,6 +278,10 @@ def truthy(v):
274
278
  return str(v).strip().lower() in ('1', 'true', 'yes', 'on')
275
279
 
276
280
 
281
+ # 浏览器能解码的图片格式。落进包的资产必须在此列,否则消费端引用到就是一张空白图。
282
+ WEB_SAFE_EXT = {'png', 'jpg', 'jpeg', 'webp', 'gif', 'svg', 'avif'}
283
+
284
+
277
285
  def asset_ext(name):
278
286
  return name.rsplit('.', 1)[-1].lower() if '.' in name else ''
279
287
 
@@ -286,6 +294,68 @@ def strip_kind_prefix(aid, kind):
286
294
  return aid
287
295
 
288
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
+
289
359
  def media_row_of(extract, source_media):
290
360
  for m in extract.get('media') or []:
291
361
  if m.get('out') and os.path.basename(m['out']) == source_media:
@@ -353,12 +423,25 @@ def place_assets(manifest, extract, stage1, pack):
353
423
  entry['path'] = 'assets/%ss/%s.%s' % (kind, base, cext)
354
424
  if truthy(a.get('use_full')):
355
425
  oext = asset_ext(row['out'])
356
- fdst = os.path.join(sub, '%s@full.%s' % (base, oext))
357
- shutil.copy2(orig_path, fdst)
358
- copied.append(fdst)
359
- entry['full'] = 'assets/%ss/%s@full.%s' % (kind, base, oext)
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))
360
436
  else:
361
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))
362
445
  dst = os.path.join(sub, '%s.%s' % (base, oext))
363
446
  shutil.copy2(orig_path, dst)
364
447
  copied.append(dst)
@@ -457,6 +540,16 @@ def expand_placeholders(body, manifest, consumer, audit, extract, layouts_text):
457
540
  f += '(原图 `%s`)' % e['full']
458
541
  rows.append('| `%s` | %s | %s |' % (aid, f, when))
459
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))
460
553
  if '{{LAYOUT_LIST}}' in body:
461
554
  rows, sect, names = [], None, {}
462
555
  for line in (layouts_text or '').split('\n'):
@@ -490,7 +583,7 @@ def render_assets_block(consumer):
490
583
 
491
584
  # ------------------------------------------------- 数值可追溯机检(抄错即 FAIL)
492
585
  HEX_RE = re.compile(r'#([0-9A-Fa-f]{6})\b')
493
- FONTSIZE_RE = re.compile(r'\bfontSize:\s*([\d.]+)px')
586
+ FONTSIZE_RE = re.compile(r'\b(?:fontSize|font-size):\s*([\d.]+)px')
494
587
  BOX_RE = re.compile(r'\bbox:\s*\[\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*,'
495
588
  r'\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*\]')
496
589
  KEYLINE_RE = re.compile(r'^(\s*)-?\s*([A-Za-z_][\w-]*):\s*(.*)$')
@@ -633,6 +726,42 @@ def _box_hit(box, idx):
633
726
  JUDGEMENT_KEYS = frozenset(('avoid', 'text_safe', 'pairing_rule'))
634
727
 
635
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
+
636
765
  def trace_check(pack, extract, shapes, derived_values, derived_tokens, factor):
637
766
  """产物里每个 hex / 字号 / slot 坐标都必须可追溯到普查值或 derived 声明。"""
638
767
  idx = build_trace_index(extract, shapes)
@@ -849,14 +978,157 @@ def build_design(manifest, l_frontmatter, consumer, body, has_sidecar, canvas):
849
978
  return text
850
979
 
851
980
 
852
- def build_layouts_md(layouts_blocks, canvas):
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):
853
1125
  blocks = {k: (inline, lines) for k, inline, lines in layouts_blocks}
854
1126
  if 'canvas' in blocks:
855
1127
  raise Fail('layouts.yaml 不要写 canvas —— 脚本从 extract.json 取')
856
1128
  if 'layouts' not in blocks:
857
1129
  raise Fail('layouts.yaml 缺顶层键 `layouts:`')
858
- # `names:` / `bg_rules:` 是给 L 层集中填判断的两块扁平区——在这里并回各
859
- # archetype,本身不进产物。让 L 层只改扁平键值,别去动 layouts 里的
1130
+ # `names:` / `roles:` / `text_roles:` / `layout_modes:` / `bg_rules:` 是给 L 层集中填判断的
1131
+ # 扁平区——在这里并回各 archetype,本身不进产物。让 L 层只改扁平键值,别去动 layouts 里的
860
1132
  # slots/confidence 结构(嵌套结构手改极易破坏缩进,进而静默改变语义)。
861
1133
  names, roles = {}, {}
862
1134
  for key, sink in (('names', names), ('roles', roles)):
@@ -864,6 +1136,17 @@ def build_layouts_md(layouts_blocks, canvas):
864
1136
  m = re.match(r'^\s{2}([\w-]+):\s*(.+?)\s*$', line)
865
1137
  if m:
866
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
867
1150
  bg_rules, cur = {}, None
868
1151
  for line in blocks.get('bg_rules', ('', []))[1]:
869
1152
  # 键后面允许行内注释(草案会标「用它的页型:…」)
@@ -880,25 +1163,102 @@ def build_layouts_md(layouts_blocks, canvas):
880
1163
  if bg_rules:
881
1164
  out.append('backgrounds:')
882
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))
883
1169
  out.append(' %s:' % bg)
884
1170
  out += lines
1171
+ modes = layout_modes(blocks.get('layout_modes', ('', []))[1])
885
1172
  out.append('layouts:')
886
- for line in blocks['layouts'][1]:
887
- out.append(line)
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:
888
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)
889
1228
  if m and m.group(1) in names:
890
1229
  out.append(' name: "%s"' % names.pop(m.group(1)))
891
1230
  if m and m.group(1) in roles:
892
1231
  out.append(' role: %s' % roles.pop(m.group(1)))
1232
+ flush_fixed_items()
893
1233
  if names:
894
1234
  raise Fail('names 里这些页型在 layouts 下找不到:%s' % ', '.join(sorted(names)))
895
1235
  if roles:
896
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)))
897
1241
  missing_role = [k for k in re.findall(r'^ ([\w-]+):\s*$', '\n'.join(blocks['layouts'][1]), re.M)
898
1242
  if not re.search(r'^ %s:\s*$(?:\n(?! \S).*)*?\n role:' % re.escape(k),
899
1243
  '\n'.join(out), re.M)]
900
1244
  if missing_role:
901
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))
902
1262
  declared = set(re.findall(r'^ background:\s*(\S+)\s*$',
903
1263
  '\n'.join(blocks['layouts'][1]), re.M))
904
1264
  stray = set(bg_rules) - declared
@@ -981,12 +1341,24 @@ def main(argv=None):
981
1341
  lay_path = os.path.join(lout, 'layouts.yaml')
982
1342
  layouts_blocks = (split_top_blocks(open(lay_path, encoding='utf-8').read())
983
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)
984
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)
985
1357
 
986
1358
  os.makedirs(pack, exist_ok=True)
987
1359
  consumer, audit, copied = place_assets(manifest, extract, stage1, pack)
988
1360
 
989
- lay_text = open(lay_path, encoding='utf-8').read() if os.path.exists(lay_path) else ''
1361
+ lay_text = final_layouts or ''
990
1362
  body = expand_placeholders(body, manifest, consumer, audit, extract, lay_text)
991
1363
  design = build_design(manifest, l_fm, consumer, body,
992
1364
  has_sidecar=layouts_blocks is not None, canvas=canvas)
@@ -994,7 +1366,7 @@ def main(argv=None):
994
1366
  f.write(design)
995
1367
  if layouts_blocks is not None:
996
1368
  with open(os.path.join(pack, 'layouts.md'), 'w', encoding='utf-8') as f:
997
- f.write(build_layouts_md(layouts_blocks, canvas))
1369
+ f.write(final_layouts)
998
1370
 
999
1371
  # 审计记录写回**抽取工作目录**,不进交付包:包是消费产物,`ref/` 里的东西
1000
1372
  # 没有任何门禁读、消费模型也用不上,放进去只会让「别读我」和「几 MB 材料」同时下发。
@@ -1042,6 +1414,12 @@ def main(argv=None):
1042
1414
  shapes = json.load(open(sp, encoding='utf-8'))['shapes'] if os.path.exists(sp) else []
1043
1415
  problems, checked = trace_check(pack, extract, shapes,
1044
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))
1045
1423
  print('\n--- 数值可追溯机检 ---')
1046
1424
  print(' 受检 hex %d / fontSize %d / slot box %d;derived 豁免 %d%s%s'
1047
1425
  % (checked['hex'], checked['size'], checked['box'], checked['exempt'],
@@ -393,6 +393,9 @@ def walk_tree(el, ctx, out, path=(), xf=(1.0, 1.0, 0.0, 0.0), depth=0):
393
393
  blip = sp.find('p:blipFill/a:blip', NS)
394
394
  if blip is not None:
395
395
  rec['media'] = ctx.media_of(blip.get(R_EMBED)) or ctx.media_of(blip.get(R_LINK))
396
+ alpha = blip.find('a:alphaModFix', NS)
397
+ if alpha is not None and alpha.get('amt') is not None:
398
+ rec['opacity'] = round(int(alpha.get('amt')) / 100000.0, 6)
396
399
  svg = blip.find('a:extLst//asvg:svgBlip', NS)
397
400
  if svg is not None:
398
401
  rec['media_svg'] = ctx.media_of(svg.get(R_EMBED))
@@ -244,14 +244,10 @@ def _recipe_css(fill, line, radii, effects):
244
244
  dash = (line or {}).get('dash')
245
245
  style = 'dashed' if dash and 'dash' in dash else 'solid'
246
246
  out.append('border: %dpx %s %s' % (w, style, lc))
247
- if radii:
247
+ if radii and all(r >= 1 for r in radii):
248
248
  lo, hi = min(radii), max(radii)
249
249
  if abs(hi - lo) <= 0.5:
250
250
  out.append('border-radius: %gpx' % round(lo, 1))
251
- else:
252
- out.append('border-radius: %gpx\x00/* 源内 %g~%gpx 共 %d 档,归一档位由 L11 定 */'
253
- % (round(sum(radii) / len(radii), 1), round(lo, 1), round(hi, 1),
254
- len(set(round(r, 1) for r in radii))))
255
251
  for e in effects or []:
256
252
  if e.get('type') == 'outerShdw':
257
253
  col = _css_color(e.get('color')) or 'rgba(0,0,0,0.25)'
@@ -267,7 +263,7 @@ def _recipe_css(fill, line, radii, effects):
267
263
  def _sig(fill, line, effects):
268
264
  """分组键 = 填充 + 描边 + 效果。**不含圆角**——OOXML 圆角是 min(w,h) 的百分比,
269
265
  同一配方在不同尺寸的卡上绝对 px 必然不同,把它计入键会把一个配方拆成多组;
270
- 归一到哪一档是判断,脚本只报区间。"""
266
+ 只有组内每个形状都明确共享同一绝对半径时才输出组级圆角,否则留给逐形状 CSS。"""
271
267
  f = 'none'
272
268
  if isinstance(fill, dict):
273
269
  if fill.get('type') == 'solid':
@@ -305,8 +301,7 @@ def cmd_recipes(a, outdir):
305
301
  g = groups.setdefault(k, {'n': 0, 'parts': Counter(), 'sizes': [], 'radii': [],
306
302
  'fill': fill, 'line': line, 'fx': fx})
307
303
  g['n'] += 1
308
- if radius:
309
- g['radii'].append(radius)
304
+ g['radii'].append(radius or 0)
310
305
  g['parts'][short(s['part'])] += 1
311
306
  b = s.get('box') or {}
312
307
  if b.get('w'):