@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.
- 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 +44 -22
- package/steering/design-html/skills/pptx-style-extract/scripts/check_v2.py +140 -2
- package/steering/design-html/skills/pptx-style-extract/scripts/draft.py +1276 -177
- package/steering/design-html/skills/pptx-style-extract/scripts/extract.py +277 -15
- package/steering/design-html/skills/pptx-style-extract/scripts/ooxml.py +18 -1
- package/steering/design-html/skills/pptx-style-extract/scripts/package.py +392 -14
- package/steering/design-html/skills/pptx-style-extract/scripts/parts.py +3 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/query.py +3 -8
- 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 +16 -7
- 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/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
|
@@ -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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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'\
|
|
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
|
|
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
|
-
|
|
887
|
-
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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'):
|