@lark-apaas/coding-steering 0.1.18-dev.4aa21f4 → 0.1.18-dev.4e64c13
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/README.md +19 -21
- package/package.json +1 -1
- package/steering/design-html/skills/animated-video/SKILL.md +2 -2
- package/steering/design-html/skills/charts/SKILL.md +48 -7
- package/steering/design-html/skills/{data-report → data-viz}/SKILL.md +65 -9
- package/steering/design-html/skills/frontend-design/SKILL.md +2 -2
- package/steering/design-html/skills/interactive-prototype/SKILL.md +35 -2
- package/steering/design-html/skills/mini-game/SKILL.md +71 -0
- package/steering/design-html/skills/mini-game/references/three-js.md +54 -0
- package/steering/design-html/skills/pptx-style-extract/SKILL.md +145 -0
- package/steering/design-html/skills/pptx-style-extract/font-fallback.yaml +129 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/census.py +961 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/check_v2.py +1022 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/draft.py +2082 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/export_consumer_md.py +75 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/export_consumer_zip.py +175 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/extract.py +848 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/ooxml.py +699 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/package.py +1204 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/parts.py +461 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/query.py +562 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/render_pages.py +685 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/verify_font.py +68 -0
- package/steering/design-html/skills/pptx-style-extract/v2-format-spec.md +198 -0
- package/steering/design-html/skills/preflight/SKILL.md +51 -0
- package/steering/design-html/skills/preflight/scripts/probe.sh +108 -0
- package/steering/design-html/skills/slide-deck/SKILL.md +165 -0
- package/steering/design-html/skills/{visual-exposure → visual-report}/SKILL.md +24 -2
- package/steering/nestjs-react-fullstack/skills_common/trigger-guide/SKILL.md +180 -0
- package/steering/nestjs-react-fullstack/{skills/trigger-guide/SKILL.md → skills_common/trigger-guide/references/trigger-lifecycle.md} +11 -162
- package/steering/design-html/skills/make-a-deck/SKILL.md +0 -193
|
@@ -0,0 +1,1204 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""S11 打包器:把「阶段一抽取产物 + L 层判断单」机械组装成风格包 v2。
|
|
3
|
+
|
|
4
|
+
python3 package.py <stage1-outdir> <l-out-dir> <pack-out-dir> [选项]
|
|
5
|
+
|
|
6
|
+
--check-v1 <path> 顺带跑 v1 门禁(需要外部 check_v1.py 路径)
|
|
7
|
+
--style-name <name> 覆盖 manifest 里的 name(包目录名仍取 pack-out-dir)
|
|
8
|
+
--force pack-out-dir 已存在且非空时照样写
|
|
9
|
+
|
|
10
|
+
本脚本零判断:所有语义来自 <l-out-dir>,所有数值来自 <stage1-outdir>/extract.json。
|
|
11
|
+
它只做四件机械事——排键序、拷资产、算审计数据、跑门禁。
|
|
12
|
+
|
|
13
|
+
================================================================================
|
|
14
|
+
L 层判断单 schema(<l-out-dir> 四个文件,这段就是填写说明书)
|
|
15
|
+
================================================================================
|
|
16
|
+
|
|
17
|
+
本脚本自带极简 YAML 读取器,只认下面这些写法,**不要用锚点/别名/复杂嵌套流**:
|
|
18
|
+
key: 标量 标量可加引号(会原样保留到产物里)
|
|
19
|
+
key: [a, b, c] 流式列表
|
|
20
|
+
key: | 或 key: > 块标量(`|` 保留换行,`>` 折成一行)
|
|
21
|
+
key: 后跟缩进块 —— 对 frontmatter.yaml / layouts.yaml
|
|
22
|
+
... 这类「原样透传」的键,块内文本**逐字节照搬**进产物,
|
|
23
|
+
所以缩进/引号/注释请按最终想要的样子写
|
|
24
|
+
|
|
25
|
+
--------------------------------------------------------------------------------
|
|
26
|
+
1) manifest.yaml —— 包身份 + 资产映射(唯一需要本脚本解析结构的文件)
|
|
27
|
+
--------------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
version: alpha # 可选,默认 alpha
|
|
30
|
+
name: azure-mist-deck # 必填,包名(英文 kebab)
|
|
31
|
+
name_zh: 碧空雾面 # 可选
|
|
32
|
+
description: > # 可选,一段话,会折成单行进 frontmatter
|
|
33
|
+
整幅铺满带颗粒感的淡蓝雾面底图……
|
|
34
|
+
themes: [dark, light] # 可选;单主题包不写
|
|
35
|
+
default-theme: dark # themes 长度 >1 时必填(V2-13)
|
|
36
|
+
theme-mechanism: "……" # 可选,进 ref/audit.yaml(不进 design.md)
|
|
37
|
+
color-confidence: {level: medium, note: "……"} # 可选,进 ref/audit.yaml
|
|
38
|
+
assets: # 可选;无资产包可整段省略
|
|
39
|
+
- id: bg-cover # 必填。命名规则 <kind 前缀>-<语义名>,
|
|
40
|
+
# logo- / slogan- / bg- / texture- / icon-
|
|
41
|
+
source_media: <原图文件名> # media-out/ 里的**原图**文件名(不是压缩版)
|
|
42
|
+
kind: background # logo|slogan|background|texture|icon
|
|
43
|
+
role: cover # background/icon 用;封闭枚举见规范 §2
|
|
44
|
+
theme: dark # 双主题包按需
|
|
45
|
+
on-bg: light # logo/slogan 用
|
|
46
|
+
mark: lockup # logo/slogan 用;**自动进 audit.yaml**
|
|
47
|
+
use_full: true # true = 同时落原图为 <name>@full.<ext>
|
|
48
|
+
recipe: "linear-gradient(…)" # 可选,CSS 重绘配方
|
|
49
|
+
confidence: high # 可选,默认 high;**自动进 audit.yaml**
|
|
50
|
+
- id: bg-surface # 纯色背景:不给 source_media,给 color
|
|
51
|
+
kind: background
|
|
52
|
+
role: content
|
|
53
|
+
color: "{colors.surface}"
|
|
54
|
+
|
|
55
|
+
说明:`boxes` / `aspect` / `canvas-source` **不要填**——脚本按 source_media 从
|
|
56
|
+
extract.json 的 images[] 直接取,写进 ref/audit.yaml。
|
|
57
|
+
|
|
58
|
+
derived: # 可选:推导值豁免声明(数值可追溯机检用)
|
|
59
|
+
- value: "1.05" # 按字面值豁免:产物里出现的该值放行
|
|
60
|
+
reason: "CJK 行高转译"
|
|
61
|
+
- value: "#5A5A5A"
|
|
62
|
+
reason: "白字 70% 不透明度的等效实色"
|
|
63
|
+
- token: light-surface # 按键名豁免:该 token(或其祖先键)下所有值放行
|
|
64
|
+
reason: "材质推导,gaps 已注明"
|
|
65
|
+
rebase_factor: 1.6 # 可选:字号等比上抬倍率;命中「普查值 × 它 ±1px」即放行
|
|
66
|
+
|
|
67
|
+
打包最后一步会校验产物里每个 hex / fontSize / slots.box 是否可追溯:
|
|
68
|
+
hex 命中 color_freq ∪ 形状与背景的填充/描边/渐变 stop 色 ∪ images[].dominant_colors
|
|
69
|
+
(每通道 ±8,采样色有量化误差);fontSize 命中 text_scale;box 命中 ref/shapes.json
|
|
70
|
+
某形状框 ±2px。命不中又没在 derived 里声明 → FAIL 并给出最近候选。
|
|
71
|
+
|
|
72
|
+
--------------------------------------------------------------------------------
|
|
73
|
+
2) frontmatter.yaml —— L 层判断产物,原样进 design.md frontmatter
|
|
74
|
+
--------------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
顶层键只允许这些(缺哪个就不写哪个,脚本按规范 §1 键序重排):
|
|
77
|
+
colors / typography / spacing / rounded / components / omitted
|
|
78
|
+
anchors / gaps / exceptions / safe-area
|
|
79
|
+
写法就是最终 frontmatter 的样子,例如:
|
|
80
|
+
|
|
81
|
+
colors:
|
|
82
|
+
surface: "#FFFFFF"
|
|
83
|
+
primary: "#RRGGBB"
|
|
84
|
+
safe-area:
|
|
85
|
+
content: {top: <px>, right: <px>, bottom: <px>, left: <px>, applies-to: [content]}
|
|
86
|
+
confidence: medium
|
|
87
|
+
|
|
88
|
+
--------------------------------------------------------------------------------
|
|
89
|
+
3) layouts.yaml —— archetype 数据,渲染成 layouts.md
|
|
90
|
+
--------------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
只需一个顶层键 `layouts:`,内容原样透传。**不要写 canvas**——脚本从
|
|
93
|
+
extract.json 取并放成 layouts.md frontmatter 首键(V2-10 / V2-R6)。
|
|
94
|
+
|
|
95
|
+
layouts:
|
|
96
|
+
cover:
|
|
97
|
+
name: "封面"
|
|
98
|
+
role: cover
|
|
99
|
+
background: bg-cover
|
|
100
|
+
slots:
|
|
101
|
+
- {role: title, box: [<x>, <y>, <w>, <h>], type: title}
|
|
102
|
+
confidence: high
|
|
103
|
+
|
|
104
|
+
可选 `body:` 块标量 —— 追加到 layouts.md frontmatter 之后作为说明正文。
|
|
105
|
+
|
|
106
|
+
--------------------------------------------------------------------------------
|
|
107
|
+
4) body.md —— design.md 正文全文
|
|
108
|
+
--------------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
从 `## Overview` 开始的全部正文(Usage / Colors / Typography / Hard Rules /
|
|
111
|
+
Exceptions……)。脚本原样拼在 frontmatter 之后,不改一个字。
|
|
112
|
+
"""
|
|
113
|
+
import argparse
|
|
114
|
+
from collections import Counter
|
|
115
|
+
import hashlib
|
|
116
|
+
import json
|
|
117
|
+
import os
|
|
118
|
+
import re
|
|
119
|
+
import shutil
|
|
120
|
+
import subprocess
|
|
121
|
+
import sys
|
|
122
|
+
|
|
123
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
124
|
+
|
|
125
|
+
# 规范 §1 键序:官方白名单 → v1 自造 → v2 自造。审计键(canvas-source /
|
|
126
|
+
# theme-mechanism / color-confidence)按 V2-R6 不进 design.md,故不在此列。
|
|
127
|
+
FRONTMATTER_ORDER = [
|
|
128
|
+
'version', 'name', 'name_zh', 'description',
|
|
129
|
+
'colors', 'typography', 'spacing', 'rounded', 'components', 'omitted',
|
|
130
|
+
'anchors', 'gaps', 'exceptions',
|
|
131
|
+
'themes', 'default-theme', 'assets', 'layouts', 'safe-area',
|
|
132
|
+
]
|
|
133
|
+
# frontmatter.yaml 允许 L 层提供的键(其余由 manifest / 脚本产出)
|
|
134
|
+
L_FRONTMATTER_KEYS = {'colors', 'typography', 'spacing', 'rounded', 'components',
|
|
135
|
+
'omitted', 'anchors', 'gaps', 'exceptions', 'safe-area'}
|
|
136
|
+
# design.md 资产条目只留消费字段,顺序固定(check_v2 V2-R6 把其余判成审计字段)
|
|
137
|
+
ASSET_CONSUMER_FIELDS = ['path', 'url', 'color', 'full', 'kind', 'role',
|
|
138
|
+
'theme', 'on-bg', 'recipe']
|
|
139
|
+
ASSET_AUDIT_FIELDS = ['boxes', 'aspect', 'mark', 'confidence']
|
|
140
|
+
KIND_PREFIX = {'background': 'bg', 'logo': 'logo', 'slogan': 'slogan',
|
|
141
|
+
'texture': 'texture', 'icon': 'icon'}
|
|
142
|
+
# 交付包的 ref/ 只留 audit.yaml(人复核数值出处用,几 KB)。
|
|
143
|
+
# 频次原表、聚类原始数据、extract.json、重建图、logo 候选图全部留在 stage1 抽取目录,
|
|
144
|
+
# 不拷进包——规范原本指望「下发时链路剥离」,但链路上没有环节真的剥离,结果
|
|
145
|
+
# 审计材料带着「别读我」一起进消费上下文,还占掉包体的大头。
|
|
146
|
+
REF_CARRY = ()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class Fail(SystemExit):
|
|
150
|
+
def __init__(self, msg):
|
|
151
|
+
super().__init__('package.py: %s' % msg)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# ------------------------------------------------------------ 极简 YAML 读取
|
|
155
|
+
def split_top_blocks(text):
|
|
156
|
+
"""顶层 `key:` 切块。返回 [(key, inline_value, block_lines)],块内逐字节保留。"""
|
|
157
|
+
out, cur = [], None
|
|
158
|
+
for raw in text.splitlines():
|
|
159
|
+
if not raw.strip() or raw.lstrip().startswith('#'):
|
|
160
|
+
if cur:
|
|
161
|
+
cur[2].append(raw)
|
|
162
|
+
continue
|
|
163
|
+
m = re.match(r'([A-Za-z_][\w-]*):(.*)$', raw)
|
|
164
|
+
if m and not raw[0].isspace():
|
|
165
|
+
cur = [m.group(1), m.group(2).strip(), []]
|
|
166
|
+
out.append(cur)
|
|
167
|
+
elif cur:
|
|
168
|
+
cur[2].append(raw)
|
|
169
|
+
else:
|
|
170
|
+
raise Fail('顶层出现无键行: %r' % raw[:60])
|
|
171
|
+
# 去掉每块尾部空行,避免透传时带出多余空白
|
|
172
|
+
for entry in out:
|
|
173
|
+
while entry[2] and not entry[2][-1].strip():
|
|
174
|
+
entry[2].pop()
|
|
175
|
+
return out
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def unquote(s):
|
|
179
|
+
s = s.strip()
|
|
180
|
+
if len(s) >= 2 and s[0] == s[-1] and s[0] in '"\'':
|
|
181
|
+
return s[1:-1]
|
|
182
|
+
# 未加引号的标量后面跟行内注释:按 YAML 规矩剥掉(前面必须有空白)
|
|
183
|
+
s = re.split(r'\s+#', s, maxsplit=1)[0].strip()
|
|
184
|
+
return s
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def scalar_of(inline, lines):
|
|
188
|
+
"""标量取值:支持 `key: v`、`key: |`、`key: >`。
|
|
189
|
+
|
|
190
|
+
`|` 按公共缩进整体 dedent,**不逐行 strip** —— 正文里的嵌套列表/缩进代码块
|
|
191
|
+
靠相对缩进表意,逐行 strip 会把它们拍平。
|
|
192
|
+
"""
|
|
193
|
+
if inline in ('|', '>', '|-', '>-'):
|
|
194
|
+
if inline.startswith('>'):
|
|
195
|
+
return ' '.join(ln.strip() for ln in lines if ln.strip()).strip()
|
|
196
|
+
indents = [len(ln) - len(ln.lstrip()) for ln in lines if ln.strip()]
|
|
197
|
+
pad = min(indents) if indents else 0
|
|
198
|
+
return '\n'.join(ln[pad:] if len(ln) >= pad else ln.lstrip()
|
|
199
|
+
for ln in lines).strip('\n')
|
|
200
|
+
return unquote(inline)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def parse_flow_list(s):
|
|
204
|
+
s = s.strip()
|
|
205
|
+
if not (s.startswith('[') and s.endswith(']')):
|
|
206
|
+
return None
|
|
207
|
+
inner = s[1:-1].strip()
|
|
208
|
+
return [unquote(x) for x in inner.split(',') if x.strip()] if inner else []
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def parse_item_list(lines):
|
|
212
|
+
"""`- key: v` 形式的对象列表。"""
|
|
213
|
+
items, cur = [], None
|
|
214
|
+
for raw in lines:
|
|
215
|
+
if not raw.strip() or raw.lstrip().startswith('#'):
|
|
216
|
+
continue
|
|
217
|
+
stripped = raw.strip()
|
|
218
|
+
if stripped.startswith('- '):
|
|
219
|
+
cur = {}
|
|
220
|
+
items.append(cur)
|
|
221
|
+
stripped = stripped[2:].strip()
|
|
222
|
+
if not stripped:
|
|
223
|
+
continue
|
|
224
|
+
if cur is None:
|
|
225
|
+
raise Fail('资产列表里出现不属于任何 `- ` 项的行: %r' % raw[:60])
|
|
226
|
+
m = re.match(r'([A-Za-z_][\w-]*):(.*)$', stripped)
|
|
227
|
+
if not m:
|
|
228
|
+
raise Fail('资产列表行无法解析: %r' % raw[:60])
|
|
229
|
+
# 值原样保留(含作者的引号):recipe / color / url 这些是直接透传进
|
|
230
|
+
# design.md 的文本,重新决定要不要加引号会改掉作者的写法。
|
|
231
|
+
cur[m.group(1)] = m.group(2).strip()
|
|
232
|
+
return items
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def read_manifest(path):
|
|
236
|
+
blocks = split_top_blocks(open(path, encoding='utf-8').read())
|
|
237
|
+
man, assets, raw = {}, [], {}
|
|
238
|
+
derived = []
|
|
239
|
+
for key, inline, lines in blocks:
|
|
240
|
+
if key == 'assets':
|
|
241
|
+
assets = parse_item_list(lines)
|
|
242
|
+
elif key == 'derived':
|
|
243
|
+
derived = parse_item_list(lines)
|
|
244
|
+
elif inline.startswith('['):
|
|
245
|
+
man[key] = parse_flow_list(inline)
|
|
246
|
+
elif inline.startswith('{'):
|
|
247
|
+
man[key] = inline # 流映射原样透传
|
|
248
|
+
else:
|
|
249
|
+
man[key] = scalar_of(inline, lines)
|
|
250
|
+
if lines:
|
|
251
|
+
# 记住原始块形态(如 `description: >` 多行),回写时逐字节照搬,
|
|
252
|
+
# 不把作者的折行改成一条长行。
|
|
253
|
+
raw[key] = (inline, lines)
|
|
254
|
+
man['assets'] = assets
|
|
255
|
+
man['derived'] = derived
|
|
256
|
+
man['_raw'] = raw
|
|
257
|
+
return man
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# ------------------------------------------------------------------- 组装
|
|
261
|
+
def yaml_scalar(v):
|
|
262
|
+
"""标量回写。已带引号 / 流式结构原样;含 YAML 危险序列才补引号。"""
|
|
263
|
+
s = str(v)
|
|
264
|
+
if not s:
|
|
265
|
+
return "''"
|
|
266
|
+
if s[0] in '"\'[{' or s[-1] in '"\']}':
|
|
267
|
+
return s
|
|
268
|
+
if re.search(r':\s|\s#|^[-?*&!|>%@`]', s) or s.strip() != s:
|
|
269
|
+
return '"%s"' % s.replace('"', '\\"')
|
|
270
|
+
return s
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def truthy(v):
|
|
274
|
+
return str(v).strip().lower() in ('1', 'true', 'yes', 'on')
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def asset_ext(name):
|
|
278
|
+
return name.rsplit('.', 1)[-1].lower() if '.' in name else ''
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def strip_kind_prefix(aid, kind):
|
|
282
|
+
"""`bg-cover` + background -> `cover`;前缀对不上就整名照用。"""
|
|
283
|
+
for pref in (KIND_PREFIX.get(kind), kind):
|
|
284
|
+
if pref and aid.startswith(pref + '-'):
|
|
285
|
+
return aid[len(pref) + 1:]
|
|
286
|
+
return aid
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def media_row_of(extract, source_media):
|
|
290
|
+
for m in extract.get('media') or []:
|
|
291
|
+
if m.get('out') and os.path.basename(m['out']) == source_media:
|
|
292
|
+
return m
|
|
293
|
+
for m in extract.get('media') or []:
|
|
294
|
+
if os.path.basename(m['media']) == source_media:
|
|
295
|
+
return m
|
|
296
|
+
return None
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def image_row_of(extract, source_media):
|
|
300
|
+
for i in extract.get('images') or []:
|
|
301
|
+
if os.path.basename(i['media']) == source_media:
|
|
302
|
+
return i
|
|
303
|
+
# An svg that ships as a raster's vector companion has no images[] row of its
|
|
304
|
+
# own — the placement is recorded on the raster (a:blip embeds the png, the svg
|
|
305
|
+
# rides in a:extLst/svgBlip). Its boxes therefore come from the companion.
|
|
306
|
+
for i in extract.get('images') or []:
|
|
307
|
+
comp = i.get('svg_companion')
|
|
308
|
+
if comp and os.path.basename(comp) == source_media:
|
|
309
|
+
return i
|
|
310
|
+
return None
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def place_assets(manifest, extract, stage1, pack):
|
|
314
|
+
"""拷贝资产 + 生成 design.md 的 assets 段 + audit.yaml 的 assets 段。"""
|
|
315
|
+
consumer, audit, copied = {}, {}, []
|
|
316
|
+
for a in manifest['assets']:
|
|
317
|
+
a = dict(a)
|
|
318
|
+
for f in ('id', 'kind', 'source_media', 'use_full', 'mark', 'confidence'):
|
|
319
|
+
if f in a:
|
|
320
|
+
a[f] = unquote(a[f])
|
|
321
|
+
aid = a.get('id')
|
|
322
|
+
if not aid:
|
|
323
|
+
raise Fail('assets 里有条目缺 id')
|
|
324
|
+
kind = a.get('kind')
|
|
325
|
+
if kind not in KIND_PREFIX:
|
|
326
|
+
raise Fail('%s: kind=%r 不在 %s 内' % (aid, kind, sorted(KIND_PREFIX)))
|
|
327
|
+
entry = {'kind': kind}
|
|
328
|
+
for f in ('role', 'theme', 'on-bg', 'recipe'):
|
|
329
|
+
if a.get(f):
|
|
330
|
+
entry[f] = a[f]
|
|
331
|
+
|
|
332
|
+
if a.get('color'):
|
|
333
|
+
entry['color'] = a['color'] # 纯色背景,不落文件
|
|
334
|
+
elif a.get('url'):
|
|
335
|
+
entry['url'] = a['url']
|
|
336
|
+
elif a.get('source_media'):
|
|
337
|
+
src = a['source_media']
|
|
338
|
+
row = media_row_of(extract, src)
|
|
339
|
+
if row is None or not row.get('out'):
|
|
340
|
+
raise Fail('%s: media-out 里找不到 %s(extract.json media 无该导出行;'
|
|
341
|
+
'非候选图需先用 extract.py --export-all-media 补导)' % (aid, src))
|
|
342
|
+
base = strip_kind_prefix(aid, kind)
|
|
343
|
+
sub = os.path.join(pack, 'assets', kind + 's')
|
|
344
|
+
os.makedirs(sub, exist_ok=True)
|
|
345
|
+
comp = row.get('compressed_out')
|
|
346
|
+
orig_path = os.path.join(stage1, row['out'])
|
|
347
|
+
if comp:
|
|
348
|
+
comp_path = os.path.join(stage1, comp)
|
|
349
|
+
cext = asset_ext(comp)
|
|
350
|
+
dst = os.path.join(sub, '%s.%s' % (base, cext))
|
|
351
|
+
shutil.copy2(comp_path, dst)
|
|
352
|
+
copied.append(dst)
|
|
353
|
+
entry['path'] = 'assets/%ss/%s.%s' % (kind, base, cext)
|
|
354
|
+
if truthy(a.get('use_full')):
|
|
355
|
+
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)
|
|
360
|
+
else:
|
|
361
|
+
oext = asset_ext(row['out'])
|
|
362
|
+
dst = os.path.join(sub, '%s.%s' % (base, oext))
|
|
363
|
+
shutil.copy2(orig_path, dst)
|
|
364
|
+
copied.append(dst)
|
|
365
|
+
entry['path'] = 'assets/%ss/%s.%s' % (kind, base, oext)
|
|
366
|
+
if truthy(a.get('use_full')):
|
|
367
|
+
raise Fail('%s: use_full=true 但 %s 没有压缩版产物,'
|
|
368
|
+
'path/full 会指向同一文件' % (aid, src))
|
|
369
|
+
else:
|
|
370
|
+
raise Fail('%s: 必须给 source_media / color / url 之一' % aid)
|
|
371
|
+
|
|
372
|
+
consumer[aid] = entry
|
|
373
|
+
# ---- 审计数据全部机器算,L 层不碰
|
|
374
|
+
au = {}
|
|
375
|
+
if a.get('source_media'):
|
|
376
|
+
img = image_row_of(extract, a['source_media'])
|
|
377
|
+
if img:
|
|
378
|
+
# 首项为主位 = 出现次数最多者;次数打平时按阅读顺序(上→下、左→右)
|
|
379
|
+
# 定序,避免同频簇的先后取决于字典插入顺序。
|
|
380
|
+
clusters = sorted(img.get('boxes') or [],
|
|
381
|
+
key=lambda c: (-c['count'], c['box']['y'], c['box']['x']))
|
|
382
|
+
boxes = [[int(round(c['box'][k])) for k in ('x', 'y', 'w', 'h')]
|
|
383
|
+
for c in clusters]
|
|
384
|
+
if boxes:
|
|
385
|
+
au['boxes'] = boxes
|
|
386
|
+
# aspect 取**未取整**的 box —— 从取整后的整数反算会明显偏
|
|
387
|
+
# 先取整再相除,误差会落到小数点后两位,logo 这种细长框尤其明显。
|
|
388
|
+
raw = clusters[0]['box']
|
|
389
|
+
if raw.get('h'):
|
|
390
|
+
au['aspect'] = round(raw['w'] / float(raw['h']), 3)
|
|
391
|
+
if a.get('mark'):
|
|
392
|
+
au['mark'] = a['mark']
|
|
393
|
+
au['confidence'] = a.get('confidence') or 'high'
|
|
394
|
+
audit[aid] = au
|
|
395
|
+
return consumer, audit, copied
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _pages_of(extract, source_media):
|
|
399
|
+
img = image_row_of(extract, source_media) if source_media else None
|
|
400
|
+
if not img:
|
|
401
|
+
return []
|
|
402
|
+
pages = set()
|
|
403
|
+
for c in img.get('boxes') or []:
|
|
404
|
+
for p in c.get('parts') or []:
|
|
405
|
+
m = re.search(r'slide(\d+)\.xml$', p)
|
|
406
|
+
if m and '/slides/' in p:
|
|
407
|
+
pages.add(int(m.group(1)))
|
|
408
|
+
return sorted(pages)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _archetypes_using(layouts_text, aid, key='asset'):
|
|
412
|
+
"""layouts.yaml 里哪些 archetype 引用了这个资产。
|
|
413
|
+
|
|
414
|
+
key='asset' 查 slots 里的图片槽;key='background' 查页型底图。
|
|
415
|
+
"""
|
|
416
|
+
out, cur, sect = [], None, None
|
|
417
|
+
for line in (layouts_text or '').split('\n'):
|
|
418
|
+
if re.match(r'^\w[\w-]*:\s*$', line):
|
|
419
|
+
sect = line.split(':')[0]
|
|
420
|
+
continue
|
|
421
|
+
if sect != 'layouts':
|
|
422
|
+
continue
|
|
423
|
+
m = re.match(r'^ ([\w-]+):\s*$', line)
|
|
424
|
+
if m:
|
|
425
|
+
cur = m.group(1)
|
|
426
|
+
continue
|
|
427
|
+
if cur and re.search(r'%s:\s*%s\b' % (key, re.escape(aid)), line) and cur not in out:
|
|
428
|
+
out.append(cur)
|
|
429
|
+
return out
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def expand_placeholders(body, manifest, consumer, audit, extract, layouts_text):
|
|
433
|
+
"""body.md 里的 `{{ASSET_TABLE}}` / `{{LAYOUT_LIST}}` 由本脚本按落盘真值渲染——
|
|
434
|
+
路径与页型清单是打包期才确定的事实,不该由 L 层手抄(抄错就是死链)。"""
|
|
435
|
+
if '{{ASSET_TABLE}}' in body:
|
|
436
|
+
rows = ['| 资产 | 文件 | 什么时候用 |', '|---|---|---|']
|
|
437
|
+
src_of = {unquote(a.get('id', '')): a.get('source_media') for a in manifest['assets']}
|
|
438
|
+
for aid, e in consumer.items():
|
|
439
|
+
pages = _pages_of(extract, src_of.get(aid))
|
|
440
|
+
box = (audit.get(aid) or {}).get('boxes') or []
|
|
441
|
+
# 单元格保持短句:哪个页型用哪张由 `background` 字段决定,
|
|
442
|
+
# 那句说明放在表格前一次即可,逐行重复只是把同一句抄 N 遍。
|
|
443
|
+
if e['kind'] == 'background':
|
|
444
|
+
users = _archetypes_using(layouts_text, aid, key='background')
|
|
445
|
+
when = ('%s 用它' % '/'.join('`%s`' % o for o in users[:6])
|
|
446
|
+
if users else '未被任何页型引用')
|
|
447
|
+
else:
|
|
448
|
+
# 位置逐页型不同(同一 logo 常在封面左上、内容页右上,尺寸也不同),
|
|
449
|
+
# 所以这里只给归属,坐标一律交给 layouts.md 的 slots。
|
|
450
|
+
owners = _archetypes_using(layouts_text, aid)
|
|
451
|
+
when = ('%s 这 %d 个页型带它' % ('/'.join('`%s`' % o for o in owners[:6]),
|
|
452
|
+
len(owners))
|
|
453
|
+
if owners else '⚠ 没有页型声明它的位置,本包不使用它')
|
|
454
|
+
f = '`%s`' % e['path'] if e.get('path') else (
|
|
455
|
+
'`%s`' % e['url'] if e.get('url') else '纯色 `%s`' % e.get('color'))
|
|
456
|
+
if e.get('full'):
|
|
457
|
+
f += '(原图 `%s`)' % e['full']
|
|
458
|
+
rows.append('| `%s` | %s | %s |' % (aid, f, when))
|
|
459
|
+
body = body.replace('{{ASSET_TABLE}}', '\n'.join(rows))
|
|
460
|
+
if '{{LAYOUT_LIST}}' in body:
|
|
461
|
+
rows, sect, names = [], None, {}
|
|
462
|
+
for line in (layouts_text or '').split('\n'):
|
|
463
|
+
if re.match(r'^\w[\w-]*:\s*$', line):
|
|
464
|
+
sect = line.split(':')[0]
|
|
465
|
+
continue
|
|
466
|
+
m = re.match(r'^ ([\w-]+):\s*(.*)$', line)
|
|
467
|
+
if not m:
|
|
468
|
+
m2 = re.match(r'^ name:\s*(.+?)\s*$', line)
|
|
469
|
+
if m2 and rows and rows[-1][1] is None:
|
|
470
|
+
rows[-1][1] = unquote(m2.group(1))
|
|
471
|
+
continue
|
|
472
|
+
if sect == 'names' and m.group(2).strip():
|
|
473
|
+
names[m.group(1)] = unquote(m.group(2))
|
|
474
|
+
elif sect == 'layouts' and not m.group(2).strip():
|
|
475
|
+
rows.append(['- `%s`' % m.group(1), names.get(m.group(1))])
|
|
476
|
+
rows = ['%s —— %s' % (k, n) if n else k for k, n in rows]
|
|
477
|
+
body = body.replace('{{LAYOUT_LIST}}', '\n'.join(rows) or '(无 archetype)')
|
|
478
|
+
return body
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def render_assets_block(consumer):
|
|
482
|
+
out = ['assets:']
|
|
483
|
+
for aid, entry in consumer.items():
|
|
484
|
+
out.append(' %s:' % aid)
|
|
485
|
+
for f in ASSET_CONSUMER_FIELDS:
|
|
486
|
+
if f in entry:
|
|
487
|
+
out.append(' %s: %s' % (f, yaml_scalar(entry[f])))
|
|
488
|
+
return out
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
# ------------------------------------------------- 数值可追溯机检(抄错即 FAIL)
|
|
492
|
+
HEX_RE = re.compile(r'#([0-9A-Fa-f]{6})\b')
|
|
493
|
+
FONTSIZE_RE = re.compile(r'\bfontSize:\s*([\d.]+)px')
|
|
494
|
+
BOX_RE = re.compile(r'\bbox:\s*\[\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*,'
|
|
495
|
+
r'\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*\]')
|
|
496
|
+
KEYLINE_RE = re.compile(r'^(\s*)-?\s*([A-Za-z_][\w-]*):\s*(.*)$')
|
|
497
|
+
DOMINANT_TOL = 8 # 采样主色的每通道容差(量化误差天然存在)
|
|
498
|
+
BOX_TOL = 2.0 # slot 坐标逐维容差
|
|
499
|
+
SIZE_TOL = 0.51 # 字号容差(px 取整误差)
|
|
500
|
+
REBASE_TOL = 1.0 # 等比上抬后的字号容差
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _hex2rgb(h):
|
|
504
|
+
h = h.lstrip('#')
|
|
505
|
+
return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def frontmatter_of(path):
|
|
509
|
+
if not os.path.exists(path):
|
|
510
|
+
return ''
|
|
511
|
+
t = open(path, encoding='utf-8').read()
|
|
512
|
+
parts = t.split('---\n')
|
|
513
|
+
return parts[1] if len(parts) >= 3 else ''
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _scan_keyed(text):
|
|
517
|
+
"""逐行产出 (行号, 该行祖先键路径, 行内容),供 token 级豁免定位。"""
|
|
518
|
+
stack = []
|
|
519
|
+
for i, raw in enumerate(text.splitlines(), 1):
|
|
520
|
+
m = KEYLINE_RE.match(raw)
|
|
521
|
+
if m:
|
|
522
|
+
indent = len(m.group(1))
|
|
523
|
+
while stack and stack[-1][0] >= indent:
|
|
524
|
+
stack.pop()
|
|
525
|
+
path = [k for _, k in stack] + [m.group(2)]
|
|
526
|
+
stack.append((indent, m.group(2)))
|
|
527
|
+
yield i, path, raw
|
|
528
|
+
else:
|
|
529
|
+
yield i, [k for _, k in stack], raw
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def build_trace_index(extract, shapes):
|
|
533
|
+
"""可命中集合:色 / 字号 / 形状框。"""
|
|
534
|
+
hexes, dominant = set(), []
|
|
535
|
+
for c in extract.get('color_freq') or []:
|
|
536
|
+
for k in ('resolved', 'hex'):
|
|
537
|
+
v = c.get(k)
|
|
538
|
+
if isinstance(v, str) and v.startswith('#') and len(v) == 7:
|
|
539
|
+
hexes.add(v.upper())
|
|
540
|
+
# 主题 clrScheme 也是普查值:出厂色常被「排除色」条款正面引用(页面频次为 0
|
|
541
|
+
# 恰恰是它要论证的事),不收进来会把这类正确引用误判成抄错。
|
|
542
|
+
for t in extract.get('themes') or []:
|
|
543
|
+
for v in (t.get('clrScheme') or {}).values():
|
|
544
|
+
if isinstance(v, str) and v.startswith('#') and len(v) == 7:
|
|
545
|
+
hexes.add(v.upper())
|
|
546
|
+
|
|
547
|
+
def eat_fill(f):
|
|
548
|
+
if not isinstance(f, dict):
|
|
549
|
+
return
|
|
550
|
+
col = f.get('color') or {}
|
|
551
|
+
for k in ('resolved', 'hex'):
|
|
552
|
+
v = col.get(k) if isinstance(col, dict) else None
|
|
553
|
+
if isinstance(v, str) and v.startswith('#') and len(v) == 7:
|
|
554
|
+
hexes.add(v.upper())
|
|
555
|
+
for st in f.get('stops') or []: # 渐变 stop 色
|
|
556
|
+
eat_fill(st if 'color' in st else {'color': st})
|
|
557
|
+
|
|
558
|
+
for grp in ('slides', 'layouts'):
|
|
559
|
+
for row in extract.get(grp) or []:
|
|
560
|
+
eat_fill(row.get('background'))
|
|
561
|
+
for s in shapes:
|
|
562
|
+
eat_fill(s.get('fill'))
|
|
563
|
+
ln = s.get('line') or {}
|
|
564
|
+
eat_fill(ln)
|
|
565
|
+
eat_fill(ln.get('gradient'))
|
|
566
|
+
for i in extract.get('images') or []:
|
|
567
|
+
for dc in i.get('dominant_colors') or []:
|
|
568
|
+
if dc.get('hex'):
|
|
569
|
+
dominant.append((_hex2rgb(dc['hex']), dc['hex'].upper()))
|
|
570
|
+
|
|
571
|
+
sizes = sorted({e['sz_px'] for e in extract.get('text_scale') or []
|
|
572
|
+
if e.get('sz_px')})
|
|
573
|
+
boxes = []
|
|
574
|
+
for s in shapes:
|
|
575
|
+
b = s.get('box')
|
|
576
|
+
if b and b.get('w') is not None:
|
|
577
|
+
boxes.append((b['x'], b['y'], b['w'], b['h'],
|
|
578
|
+
'%s %s' % (os.path.basename(s['part']), s.get('name') or s['kind'])))
|
|
579
|
+
return {'hexes': hexes, 'dominant': dominant, 'sizes': sizes, 'boxes': boxes}
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def _hex_hit(value, idx):
|
|
583
|
+
v = value.upper()
|
|
584
|
+
if v in idx['hexes']:
|
|
585
|
+
return True, None
|
|
586
|
+
rgb = _hex2rgb(v)
|
|
587
|
+
best, bestd = None, None
|
|
588
|
+
for drgb, dhex in idx['dominant']:
|
|
589
|
+
d = max(abs(a - b) for a, b in zip(rgb, drgb))
|
|
590
|
+
if d <= DOMINANT_TOL:
|
|
591
|
+
return True, None
|
|
592
|
+
if bestd is None or d < bestd:
|
|
593
|
+
best, bestd = dhex, d
|
|
594
|
+
for h in idx['hexes']:
|
|
595
|
+
d = max(abs(a - b) for a, b in zip(rgb, _hex2rgb(h)))
|
|
596
|
+
if bestd is None or d < bestd:
|
|
597
|
+
best, bestd = h, d
|
|
598
|
+
return False, ('最近 %s(每通道差 %d)' % (best, bestd) if best else '普查里无任何色值')
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def _size_hit(v, idx, factor):
|
|
602
|
+
for s in idx['sizes']:
|
|
603
|
+
if abs(s - v) <= SIZE_TOL:
|
|
604
|
+
return True, None
|
|
605
|
+
if factor and abs(s * factor - v) <= REBASE_TOL:
|
|
606
|
+
return True, None
|
|
607
|
+
if not idx['sizes']:
|
|
608
|
+
return False, '普查里无字号'
|
|
609
|
+
near = min(idx['sizes'], key=lambda s: abs(s - v))
|
|
610
|
+
tip = '最近 %gpx' % near
|
|
611
|
+
if factor:
|
|
612
|
+
nf = min(idx['sizes'], key=lambda s: abs(s * factor - v))
|
|
613
|
+
tip += ';×%g 后最近 %gpx→%.1f' % (factor, nf, nf * factor)
|
|
614
|
+
return False, tip
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def _box_hit(box, idx):
|
|
618
|
+
best, bestd = None, None
|
|
619
|
+
for x, y, w, h, tag in idx['boxes']:
|
|
620
|
+
d = max(abs(box[0] - x), abs(box[1] - y), abs(box[2] - w), abs(box[3] - h))
|
|
621
|
+
if d <= BOX_TOL:
|
|
622
|
+
return True, None
|
|
623
|
+
if bestd is None or d < bestd:
|
|
624
|
+
best, bestd = (x, y, w, h, tag), d
|
|
625
|
+
if best is None:
|
|
626
|
+
return False, 'shapes.json 无形状'
|
|
627
|
+
return False, ('最近 [%g, %g, %g, %g](%s,最大维差 %.1fpx)'
|
|
628
|
+
% (best[0], best[1], best[2], best[3], best[4], bestd))
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
# 判断产物:L 层看着背景图划出来的区域,本就不该命中普查值——整键豁免,
|
|
632
|
+
# 不要求写 derived。逼它们走 derived 只会把整段豁免掉,机检反而更弱。
|
|
633
|
+
JUDGEMENT_KEYS = frozenset(('avoid', 'text_safe', 'pairing_rule'))
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def media_integrity(stage1, extract):
|
|
637
|
+
"""extract.json 记录的尺寸,必须和磁盘上那个文件真实的尺寸一致。
|
|
638
|
+
|
|
639
|
+
这条挡的是「记录全对、文件被顶替」——两个 media 的输出名撞车时,后写的覆盖先写的,
|
|
640
|
+
extract.json 里各自的记录都还是对的,任何只读记录的检查都发现不了;只有把记录和
|
|
641
|
+
文件本身对一遍才抓得到。实测某模板因此把一张 109x109 的图当成了整页背景。
|
|
642
|
+
"""
|
|
643
|
+
try:
|
|
644
|
+
from PIL import Image
|
|
645
|
+
except Exception:
|
|
646
|
+
return [] # 没有 Pillow 就量不了,跳过而不是假装通过
|
|
647
|
+
bad, seen = [], {}
|
|
648
|
+
for m in extract.get('media') or []:
|
|
649
|
+
for key, rel in (('source_px', m.get('out')),
|
|
650
|
+
('compressed_px', m.get('compressed_out'))):
|
|
651
|
+
rec = m.get(key)
|
|
652
|
+
if not rel or not rec:
|
|
653
|
+
continue
|
|
654
|
+
path = os.path.join(stage1, rel)
|
|
655
|
+
if not os.path.exists(path):
|
|
656
|
+
bad.append('%s 的 %s 指向 %s,文件不存在' % (m['media'], key, rel))
|
|
657
|
+
continue
|
|
658
|
+
try:
|
|
659
|
+
with Image.open(path) as im:
|
|
660
|
+
real = list(im.size)
|
|
661
|
+
except Exception:
|
|
662
|
+
continue
|
|
663
|
+
if real != list(rec):
|
|
664
|
+
bad.append('%s 的 %s 记录 %s,但 %s 实际是 %s'
|
|
665
|
+
% (m['media'], key, rec, rel, real))
|
|
666
|
+
if rel in seen and seen[rel] != m['media']:
|
|
667
|
+
bad.append('%s 与 %s 都写到 %s' % (seen[rel], m['media'], rel))
|
|
668
|
+
seen[rel] = m['media']
|
|
669
|
+
return bad
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def trace_check(pack, extract, shapes, derived_values, derived_tokens, factor):
|
|
673
|
+
"""产物里每个 hex / 字号 / slot 坐标都必须可追溯到普查值或 derived 声明。"""
|
|
674
|
+
idx = build_trace_index(extract, shapes)
|
|
675
|
+
problems, checked = [], Counter()
|
|
676
|
+
targets = [('design.md', frontmatter_of(os.path.join(pack, 'design.md'))),
|
|
677
|
+
('layouts.md', frontmatter_of(os.path.join(pack, 'layouts.md')))]
|
|
678
|
+
for fname, text in targets:
|
|
679
|
+
if not text:
|
|
680
|
+
continue
|
|
681
|
+
for lineno, path, raw in _scan_keyed(text):
|
|
682
|
+
if JUDGEMENT_KEYS & set(path):
|
|
683
|
+
checked['judgement'] += 1
|
|
684
|
+
continue
|
|
685
|
+
exempt_token = any(k in derived_tokens for k in path)
|
|
686
|
+
for m in HEX_RE.finditer(raw):
|
|
687
|
+
val = '#' + m.group(1).upper()
|
|
688
|
+
checked['hex'] += 1
|
|
689
|
+
if exempt_token or val in derived_values:
|
|
690
|
+
checked['exempt'] += 1
|
|
691
|
+
continue
|
|
692
|
+
ok, tip = _hex_hit(val, idx)
|
|
693
|
+
if not ok:
|
|
694
|
+
problems.append(('hex', fname, lineno, val, tip, '.'.join(path)))
|
|
695
|
+
for m in FONTSIZE_RE.finditer(raw):
|
|
696
|
+
val = float(m.group(1))
|
|
697
|
+
checked['size'] += 1
|
|
698
|
+
if exempt_token or m.group(1) in derived_values:
|
|
699
|
+
checked['exempt'] += 1
|
|
700
|
+
continue
|
|
701
|
+
ok, tip = _size_hit(val, idx, factor)
|
|
702
|
+
if not ok:
|
|
703
|
+
problems.append(('fontSize', fname, lineno, '%gpx' % val, tip,
|
|
704
|
+
'.'.join(path)))
|
|
705
|
+
for m in BOX_RE.finditer(raw):
|
|
706
|
+
box = [float(x) for x in m.groups()]
|
|
707
|
+
checked['box'] += 1
|
|
708
|
+
key = '[%s]' % ', '.join(m.groups())
|
|
709
|
+
if exempt_token or key in derived_values:
|
|
710
|
+
checked['exempt'] += 1
|
|
711
|
+
continue
|
|
712
|
+
ok, tip = _box_hit(box, idx)
|
|
713
|
+
if not ok:
|
|
714
|
+
problems.append(('slot box', fname, lineno,
|
|
715
|
+
'[%g, %g, %g, %g]' % tuple(box), tip, '.'.join(path)))
|
|
716
|
+
return problems, checked
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def render_audit(manifest, extract, audit):
|
|
720
|
+
L = ['# 审计元数据(从 design.md 剥离,消费模型不需要;机检与人工复核用)']
|
|
721
|
+
src = extract['canvas']['source']
|
|
722
|
+
L.append('canvas-source: {cx: %d, cy: %d, unit: %s}'
|
|
723
|
+
% (src['cx'], src['cy'], src.get('unit', 'EMU')))
|
|
724
|
+
for k in ('theme-mechanism', 'color-confidence'):
|
|
725
|
+
if manifest.get(k):
|
|
726
|
+
L.append('%s: %s' % (k, yaml_scalar(manifest[k])))
|
|
727
|
+
if audit:
|
|
728
|
+
L.append('assets:')
|
|
729
|
+
for aid, au in audit.items():
|
|
730
|
+
L.append(' %s:' % aid)
|
|
731
|
+
if au.get('boxes'):
|
|
732
|
+
L.append(' boxes:')
|
|
733
|
+
for b in au['boxes']:
|
|
734
|
+
L.append(' - [%d, %d, %d, %d]' % tuple(b))
|
|
735
|
+
for f in ('aspect', 'mark', 'confidence'):
|
|
736
|
+
if f in au:
|
|
737
|
+
L.append(' %s: %s' % (f, yaml_scalar(au[f])))
|
|
738
|
+
return '\n'.join(L) + '\n'
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def _sha256(path):
|
|
742
|
+
h = hashlib.sha256()
|
|
743
|
+
with open(path, 'rb') as f:
|
|
744
|
+
for chunk in iter(lambda: f.read(1024 * 1024), b''):
|
|
745
|
+
h.update(chunk)
|
|
746
|
+
return h.hexdigest()
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def _rel_files(root):
|
|
750
|
+
files = []
|
|
751
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
752
|
+
dirnames.sort()
|
|
753
|
+
for name in sorted(filenames):
|
|
754
|
+
path = os.path.join(dirpath, name)
|
|
755
|
+
files.append(os.path.relpath(path, root))
|
|
756
|
+
return files
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
def build_package_manifest(manifest, consumer, audit, extract, pack):
|
|
760
|
+
"""Machine index for the style package.
|
|
761
|
+
|
|
762
|
+
design.md remains the consumer entry. manifest.json is for storage/control
|
|
763
|
+
planes: stable identity plus file and asset checksums.
|
|
764
|
+
"""
|
|
765
|
+
files = []
|
|
766
|
+
for rel in _rel_files(pack):
|
|
767
|
+
if rel == 'manifest.json':
|
|
768
|
+
continue
|
|
769
|
+
path = os.path.join(pack, rel)
|
|
770
|
+
files.append({
|
|
771
|
+
'path': rel,
|
|
772
|
+
'bytes': os.path.getsize(path),
|
|
773
|
+
'sha256': _sha256(path),
|
|
774
|
+
})
|
|
775
|
+
|
|
776
|
+
assets = []
|
|
777
|
+
for aid, entry in consumer.items():
|
|
778
|
+
item = {'id': aid, 'kind': entry.get('kind')}
|
|
779
|
+
for key in ('role', 'theme', 'on-bg', 'path', 'full', 'url', 'color'):
|
|
780
|
+
if entry.get(key):
|
|
781
|
+
item[key] = entry[key]
|
|
782
|
+
for key in ('path', 'full'):
|
|
783
|
+
rel = entry.get(key)
|
|
784
|
+
if rel:
|
|
785
|
+
path = os.path.join(pack, rel)
|
|
786
|
+
if os.path.exists(path):
|
|
787
|
+
item[key + '_bytes'] = os.path.getsize(path)
|
|
788
|
+
item[key + '_sha256'] = _sha256(path)
|
|
789
|
+
au = audit.get(aid) or {}
|
|
790
|
+
if au.get('confidence'):
|
|
791
|
+
item['confidence'] = au['confidence']
|
|
792
|
+
assets.append(item)
|
|
793
|
+
|
|
794
|
+
files_digest = hashlib.sha256()
|
|
795
|
+
for item in files:
|
|
796
|
+
files_digest.update(item['path'].encode('utf-8'))
|
|
797
|
+
files_digest.update(b'\0')
|
|
798
|
+
files_digest.update(item['sha256'].encode('ascii'))
|
|
799
|
+
files_digest.update(b'\0')
|
|
800
|
+
files_digest.update(str(item['bytes']).encode('ascii'))
|
|
801
|
+
files_digest.update(b'\n')
|
|
802
|
+
|
|
803
|
+
pkg = {
|
|
804
|
+
'schemaVersion': 'pptx-style-package/v2',
|
|
805
|
+
'id': manifest.get('name'),
|
|
806
|
+
'name': manifest.get('name'),
|
|
807
|
+
'name_zh': manifest.get('name_zh'),
|
|
808
|
+
'version': manifest.get('version') or 'alpha',
|
|
809
|
+
'description': manifest.get('description'),
|
|
810
|
+
'entry': 'design.md',
|
|
811
|
+
'layouts': 'layouts.md' if os.path.exists(os.path.join(pack, 'layouts.md')) else None,
|
|
812
|
+
'canvas': '%dx%d' % tuple(extract['canvas']['px']),
|
|
813
|
+
'source': {
|
|
814
|
+
'filename': (extract.get('source') or {}).get('filename'),
|
|
815
|
+
'content_type_kind': (extract.get('source') or {}).get('content_type_kind'),
|
|
816
|
+
'is_template': (extract.get('source') or {}).get('is_template'),
|
|
817
|
+
'bytes': (extract.get('source') or {}).get('bytes'),
|
|
818
|
+
},
|
|
819
|
+
'themes': manifest.get('themes') or ['single'],
|
|
820
|
+
'default_theme': manifest.get('default-theme'),
|
|
821
|
+
'files': files,
|
|
822
|
+
'assets': assets,
|
|
823
|
+
'totals': {
|
|
824
|
+
'file_count': len(files),
|
|
825
|
+
'bytes': sum(f['bytes'] for f in files),
|
|
826
|
+
'sha256': files_digest.hexdigest(),
|
|
827
|
+
'asset_count': len(assets),
|
|
828
|
+
},
|
|
829
|
+
}
|
|
830
|
+
return {k: v for k, v in pkg.items() if v is not None}
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
def strip_agent_fast_path(body):
|
|
834
|
+
if '## Agent Fast Path' not in body:
|
|
835
|
+
return body
|
|
836
|
+
return re.sub(r'\n?## Agent Fast Path\n.*?(?=\n## |\Z)', '\n', body,
|
|
837
|
+
count=1, flags=re.S).lstrip('\n')
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
def build_design(manifest, l_frontmatter, consumer, body, has_sidecar, canvas):
|
|
841
|
+
blocks = {k: (inline, lines) for k, inline, lines in l_frontmatter}
|
|
842
|
+
bad = set(blocks) - L_FRONTMATTER_KEYS
|
|
843
|
+
if bad:
|
|
844
|
+
raise Fail('frontmatter.yaml 出现不该由 L 层提供的顶层键: %s'
|
|
845
|
+
'(身份键写 manifest.yaml,assets/layouts 由脚本生成)'
|
|
846
|
+
% ', '.join(sorted(bad)))
|
|
847
|
+
out = ['---']
|
|
848
|
+
for key in FRONTMATTER_ORDER:
|
|
849
|
+
if key in ('version', 'name', 'name_zh', 'description'):
|
|
850
|
+
block = (manifest.get('_raw') or {}).get(key)
|
|
851
|
+
if block:
|
|
852
|
+
out.append('%s:%s' % (key, (' ' + block[0]) if block[0] else ''))
|
|
853
|
+
out += block[1]
|
|
854
|
+
continue
|
|
855
|
+
v = manifest.get(key) or ('alpha' if key == 'version' else None)
|
|
856
|
+
if v:
|
|
857
|
+
out.append('%s: %s' % (key, yaml_scalar(v)))
|
|
858
|
+
elif key in ('themes', 'default-theme'):
|
|
859
|
+
v = manifest.get(key)
|
|
860
|
+
if isinstance(v, list):
|
|
861
|
+
out.append('%s: [%s]' % (key, ', '.join(v)))
|
|
862
|
+
elif v:
|
|
863
|
+
out.append('%s: %s' % (key, yaml_scalar(v)))
|
|
864
|
+
elif key == 'assets':
|
|
865
|
+
if consumer:
|
|
866
|
+
out += render_assets_block(consumer)
|
|
867
|
+
elif key == 'layouts':
|
|
868
|
+
if has_sidecar:
|
|
869
|
+
out.append('layouts: layouts.md')
|
|
870
|
+
elif key in blocks:
|
|
871
|
+
inline, lines = blocks[key]
|
|
872
|
+
out.append('%s:%s' % (key, (' ' + inline) if inline else ''))
|
|
873
|
+
out += lines
|
|
874
|
+
if key == 'exceptions' and not has_sidecar and canvas:
|
|
875
|
+
out.append('canvas: %dx%d' % tuple(canvas))
|
|
876
|
+
out.append('---')
|
|
877
|
+
text = '\n'.join(out) + '\n'
|
|
878
|
+
# Fast Path 已废弃:它是 Usage / Hard Rules / 资产表 / 页型清单的第二份副本。
|
|
879
|
+
# 规则的唯一出处是正文各段,这里只负责把历史产物里的残留剥掉。
|
|
880
|
+
body = strip_agent_fast_path(body)
|
|
881
|
+
if body:
|
|
882
|
+
text += body if body.startswith('\n') else '\n' + body
|
|
883
|
+
if not text.endswith('\n'):
|
|
884
|
+
text += '\n'
|
|
885
|
+
return text
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
def shrink_safe_area(lines):
|
|
889
|
+
"""text_safe 与 avoid 相交时把安全区收掉重叠的那部分。
|
|
890
|
+
|
|
891
|
+
text_safe 是脚本按「该背景各页型的槽位并集」算的,而模板里的槽位本身可能就压在
|
|
892
|
+
主视觉上;avoid 是看图的人填的。两者从不同来源来,会直接打架——实测一张背景的
|
|
893
|
+
text_safe 有 34% 落在 avoid 里,消费端按 text_safe 把标题放进了禁放区,正好压在
|
|
894
|
+
背景的山峰主体上。这里以 avoid 为准收缩:能不能放字是看图的人说了算。
|
|
895
|
+
"""
|
|
896
|
+
def parse_box(s):
|
|
897
|
+
m = re.findall(r'-?\d+', s)
|
|
898
|
+
return [int(x) for x in m[:4]] if len(m) >= 4 else None
|
|
899
|
+
|
|
900
|
+
ts_i = ts = None
|
|
901
|
+
avoids = []
|
|
902
|
+
for i, ln in enumerate(lines):
|
|
903
|
+
st = ln.strip()
|
|
904
|
+
if st.startswith('text_safe:'):
|
|
905
|
+
ts_i, ts = i, parse_box(st.split(':', 1)[1].split('#')[0])
|
|
906
|
+
elif st.startswith('avoid:'):
|
|
907
|
+
for chunk in re.findall(r'box:\s*\[[^\]]*\]', st):
|
|
908
|
+
b = parse_box(chunk)
|
|
909
|
+
if b:
|
|
910
|
+
avoids.append(b)
|
|
911
|
+
if ts is None or not avoids:
|
|
912
|
+
return lines, None
|
|
913
|
+
x0, y0, x1, y1 = ts[0], ts[1], ts[0] + ts[2], ts[1] + ts[3]
|
|
914
|
+
for a in avoids:
|
|
915
|
+
ax0, ay0, ax1, ay1 = a[0], a[1], a[0] + a[2], a[1] + a[3]
|
|
916
|
+
if ax1 <= x0 or ax0 >= x1 or ay1 <= y0 or ay0 >= y1:
|
|
917
|
+
continue # 不相交
|
|
918
|
+
# 从被侵占得最少的一边切:优先保留面积最大的剩余矩形
|
|
919
|
+
cands = []
|
|
920
|
+
if ax0 > x0:
|
|
921
|
+
cands.append((x0, y0, ax0, y1))
|
|
922
|
+
if ax1 < x1:
|
|
923
|
+
cands.append((ax1, y0, x1, y1))
|
|
924
|
+
if ay0 > y0:
|
|
925
|
+
cands.append((x0, y0, x1, ay0))
|
|
926
|
+
if ay1 < y1:
|
|
927
|
+
cands.append((x0, ay1, x1, y1))
|
|
928
|
+
if not cands:
|
|
929
|
+
return lines, 'text_safe 被 avoid 完全覆盖,这张背景没有可放文字的区域'
|
|
930
|
+
x0, y0, x1, y1 = max(cands, key=lambda c: (c[2] - c[0]) * (c[3] - c[1]))
|
|
931
|
+
new = [x0, y0, x1 - x0, y1 - y0]
|
|
932
|
+
if new == ts:
|
|
933
|
+
return lines, None
|
|
934
|
+
lines = list(lines)
|
|
935
|
+
lines[ts_i] = ' text_safe: [%d, %d, %d, %d] # 已按 avoid 收缩(原 %s)' % (
|
|
936
|
+
new[0], new[1], new[2], new[3], ts)
|
|
937
|
+
return lines, None
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
def build_layouts_md(layouts_blocks, canvas):
|
|
941
|
+
blocks = {k: (inline, lines) for k, inline, lines in layouts_blocks}
|
|
942
|
+
if 'canvas' in blocks:
|
|
943
|
+
raise Fail('layouts.yaml 不要写 canvas —— 脚本从 extract.json 取')
|
|
944
|
+
if 'layouts' not in blocks:
|
|
945
|
+
raise Fail('layouts.yaml 缺顶层键 `layouts:`')
|
|
946
|
+
# `names:` / `bg_rules:` 是给 L 层集中填判断的两块扁平区——在这里并回各
|
|
947
|
+
# archetype,本身不进产物。让 L 层只改扁平键值,别去动 layouts 里的
|
|
948
|
+
# slots/confidence 结构(嵌套结构手改极易破坏缩进,进而静默改变语义)。
|
|
949
|
+
names, roles = {}, {}
|
|
950
|
+
for key, sink in (('names', names), ('roles', roles)):
|
|
951
|
+
for line in blocks.get(key, ('', []))[1]:
|
|
952
|
+
m = re.match(r'^\s{2}([\w-]+):\s*(.+?)\s*$', line)
|
|
953
|
+
if m:
|
|
954
|
+
sink[m.group(1)] = unquote(m.group(2))
|
|
955
|
+
bg_rules, cur = {}, None
|
|
956
|
+
for line in blocks.get('bg_rules', ('', []))[1]:
|
|
957
|
+
# 键后面允许行内注释(草案会标「用它的页型:…」)
|
|
958
|
+
m = re.match(r'^\s{2}([\w-]+):\s*(?:#.*)?$', line)
|
|
959
|
+
if m:
|
|
960
|
+
cur = m.group(1)
|
|
961
|
+
bg_rules[cur] = []
|
|
962
|
+
continue
|
|
963
|
+
if cur and line.strip():
|
|
964
|
+
bg_rules[cur].append(' ' + line.strip())
|
|
965
|
+
out = ['---', 'canvas: %dx%d' % tuple(canvas)]
|
|
966
|
+
# 禁放区是背景的属性,按背景写一次;页型只留 `background:` 指针。
|
|
967
|
+
# 合并进每个页型会把同一句话按页型数复制 N 遍。
|
|
968
|
+
if bg_rules:
|
|
969
|
+
out.append('backgrounds:')
|
|
970
|
+
for bg, lines in bg_rules.items():
|
|
971
|
+
lines, err = shrink_safe_area(lines)
|
|
972
|
+
if err:
|
|
973
|
+
raise Fail('%s: %s' % (bg, err))
|
|
974
|
+
out.append(' %s:' % bg)
|
|
975
|
+
out += lines
|
|
976
|
+
out.append('layouts:')
|
|
977
|
+
for line in blocks['layouts'][1]:
|
|
978
|
+
# 判断单里的结构事实(栅格、间距序列、样张字数、命中配方)是给 L 层判断用的,
|
|
979
|
+
# 不进产物——消费端要的是结论,不是推导过程。
|
|
980
|
+
if line.lstrip().startswith('#'):
|
|
981
|
+
continue
|
|
982
|
+
out.append(line)
|
|
983
|
+
m = re.match(r'^ ([\w-]+):\s*$', line)
|
|
984
|
+
if m and m.group(1) in names:
|
|
985
|
+
out.append(' name: "%s"' % names.pop(m.group(1)))
|
|
986
|
+
if m and m.group(1) in roles:
|
|
987
|
+
out.append(' role: %s' % roles.pop(m.group(1)))
|
|
988
|
+
if names:
|
|
989
|
+
raise Fail('names 里这些页型在 layouts 下找不到:%s' % ', '.join(sorted(names)))
|
|
990
|
+
if roles:
|
|
991
|
+
raise Fail('roles 里这些页型在 layouts 下找不到:%s' % ', '.join(sorted(roles)))
|
|
992
|
+
missing_role = [k for k in re.findall(r'^ ([\w-]+):\s*$', '\n'.join(blocks['layouts'][1]), re.M)
|
|
993
|
+
if not re.search(r'^ %s:\s*$(?:\n(?! \S).*)*?\n role:' % re.escape(k),
|
|
994
|
+
'\n'.join(out), re.M)]
|
|
995
|
+
if missing_role:
|
|
996
|
+
raise Fail('这些页型没有 role(在 layouts.yaml 的 roles 段填):%s' % ', '.join(missing_role))
|
|
997
|
+
# flow 与 slots 二选一:两份都留下,消费端不知道该听哪份坐标——design.md 正文与
|
|
998
|
+
# layouts.md 打架就是这么来的,别重演。
|
|
999
|
+
both, cur, has = [], None, {}
|
|
1000
|
+
for line in blocks['layouts'][1]:
|
|
1001
|
+
m = re.match(r'^ ([\w-]+):\s*$', line)
|
|
1002
|
+
if m:
|
|
1003
|
+
if cur and has.get('flow') and has.get('slots'):
|
|
1004
|
+
both.append(cur)
|
|
1005
|
+
cur, has = m.group(1), {}
|
|
1006
|
+
continue
|
|
1007
|
+
m = re.match(r'^ (flow|slots):\s*$', line)
|
|
1008
|
+
if m:
|
|
1009
|
+
has[m.group(1)] = True
|
|
1010
|
+
if cur and has.get('flow') and has.get('slots'):
|
|
1011
|
+
both.append(cur)
|
|
1012
|
+
if both:
|
|
1013
|
+
raise Fail('这些页型同时留了 flow 和 slots,二选一删掉另一个:%s' % ', '.join(both))
|
|
1014
|
+
declared = set(re.findall(r'^ background:\s*(\S+)\s*$',
|
|
1015
|
+
'\n'.join(blocks['layouts'][1]), re.M))
|
|
1016
|
+
stray = set(bg_rules) - declared
|
|
1017
|
+
if stray:
|
|
1018
|
+
raise Fail('backgrounds 段里这些背景没有任何页型在用:%s' % ', '.join(sorted(stray)))
|
|
1019
|
+
out.append('---')
|
|
1020
|
+
text = '\n'.join(out) + '\n'
|
|
1021
|
+
if 'body' in blocks:
|
|
1022
|
+
body = scalar_of(blocks['body'][0], blocks['body'][1])
|
|
1023
|
+
if body:
|
|
1024
|
+
text += '\n' + body.rstrip('\n') + '\n'
|
|
1025
|
+
return text
|
|
1026
|
+
|
|
1027
|
+
|
|
1028
|
+
# --------------------------------------------------------------------- 主流程
|
|
1029
|
+
|
|
1030
|
+
def _autodetect_check_v1():
|
|
1031
|
+
skill_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
1032
|
+
for cand in (os.environ.get('DSM_V1_DIR'),
|
|
1033
|
+
os.path.join(os.path.dirname(skill_root), 'miaoda-design-system-extract'),
|
|
1034
|
+
os.path.expanduser('~/dev/gitlab/miaoda_workspace/.agents/skills/miaoda-design-system-extract')):
|
|
1035
|
+
if cand and os.path.isfile(os.path.join(cand, 'scripts', 'check_v1.py')):
|
|
1036
|
+
return os.path.join(cand, 'scripts', 'check_v1.py')
|
|
1037
|
+
return None
|
|
1038
|
+
|
|
1039
|
+
|
|
1040
|
+
def main(argv=None):
|
|
1041
|
+
ap = argparse.ArgumentParser(add_help=True)
|
|
1042
|
+
ap.add_argument('stage1')
|
|
1043
|
+
ap.add_argument('lout')
|
|
1044
|
+
ap.add_argument('pack')
|
|
1045
|
+
ap.add_argument('--check-v1', default=None, help='check_v1.py 路径;缺省自动探测($DSM_V1_DIR → 同级 skill → 开发机路径)')
|
|
1046
|
+
ap.add_argument('--style-name', default=None)
|
|
1047
|
+
ap.add_argument('--force', action='store_true')
|
|
1048
|
+
args = ap.parse_args(argv)
|
|
1049
|
+
|
|
1050
|
+
stage1, lout, pack = (os.path.abspath(p) for p in (args.stage1, args.lout, args.pack))
|
|
1051
|
+
ex_path = os.path.join(stage1, 'extract.json')
|
|
1052
|
+
if not os.path.exists(ex_path):
|
|
1053
|
+
raise Fail('找不到 %s' % ex_path)
|
|
1054
|
+
extract = json.load(open(ex_path, encoding='utf-8'))
|
|
1055
|
+
|
|
1056
|
+
need = ['manifest.yaml', 'frontmatter.yaml', 'body.md']
|
|
1057
|
+
for f in need:
|
|
1058
|
+
if not os.path.exists(os.path.join(lout, f)):
|
|
1059
|
+
raise Fail('判断单缺 %s(schema 见本脚本 docstring)' % f)
|
|
1060
|
+
if os.path.isdir(pack) and os.listdir(pack) and not args.force:
|
|
1061
|
+
raise Fail('%s 非空;加 --force 覆盖' % pack)
|
|
1062
|
+
|
|
1063
|
+
left = []
|
|
1064
|
+
for f in ('manifest.yaml', 'frontmatter.yaml', 'layouts.yaml', 'body.md'):
|
|
1065
|
+
p = os.path.join(lout, f)
|
|
1066
|
+
if not os.path.exists(p):
|
|
1067
|
+
continue
|
|
1068
|
+
for i, line in enumerate(open(p, encoding='utf-8'), 1):
|
|
1069
|
+
if 'TODO' in line:
|
|
1070
|
+
left.append('%s:%d %s' % (f, i, line.strip()[:90]))
|
|
1071
|
+
if left:
|
|
1072
|
+
raise Fail('判断单还有 %d 处草案占位没改(TODO 是 draft.py 留给 L 层的判断点):\n %s'
|
|
1073
|
+
% (len(left), '\n '.join(left)))
|
|
1074
|
+
mdesc = re.search(r'^description:\s*[|>]?\s*\n((?:\s+\S.*\n?)+)',
|
|
1075
|
+
open(os.path.join(lout, 'manifest.yaml'), encoding='utf-8').read(), re.M)
|
|
1076
|
+
if mdesc and len(re.sub(r'\s+', '', mdesc.group(1))) < 20:
|
|
1077
|
+
raise Fail('manifest 的 description 只有 %d 个字符——它是消费模型定调的唯一入口,'
|
|
1078
|
+
'写清底色/主色/字形/版面骨架,不要留占位'
|
|
1079
|
+
% len(re.sub(r'\s+', '', mdesc.group(1))))
|
|
1080
|
+
|
|
1081
|
+
manifest = read_manifest(os.path.join(lout, 'manifest.yaml'))
|
|
1082
|
+
if args.style_name:
|
|
1083
|
+
manifest['name'] = args.style_name
|
|
1084
|
+
if not manifest.get('name'):
|
|
1085
|
+
raise Fail('manifest.yaml 缺 name')
|
|
1086
|
+
themes = manifest.get('themes')
|
|
1087
|
+
if isinstance(themes, list) and len(themes) > 1 and not manifest.get('default-theme'):
|
|
1088
|
+
raise Fail('themes 有 %d 个主题,必须给 default-theme(V2-13)' % len(themes))
|
|
1089
|
+
|
|
1090
|
+
l_fm = split_top_blocks(open(os.path.join(lout, 'frontmatter.yaml'),
|
|
1091
|
+
encoding='utf-8').read())
|
|
1092
|
+
body = open(os.path.join(lout, 'body.md'), encoding='utf-8').read()
|
|
1093
|
+
lay_path = os.path.join(lout, 'layouts.yaml')
|
|
1094
|
+
layouts_blocks = (split_top_blocks(open(lay_path, encoding='utf-8').read())
|
|
1095
|
+
if os.path.exists(lay_path) else None)
|
|
1096
|
+
canvas = extract['canvas']['px']
|
|
1097
|
+
|
|
1098
|
+
os.makedirs(pack, exist_ok=True)
|
|
1099
|
+
consumer, audit, copied = place_assets(manifest, extract, stage1, pack)
|
|
1100
|
+
|
|
1101
|
+
lay_text = open(lay_path, encoding='utf-8').read() if os.path.exists(lay_path) else ''
|
|
1102
|
+
body = expand_placeholders(body, manifest, consumer, audit, extract, lay_text)
|
|
1103
|
+
design = build_design(manifest, l_fm, consumer, body,
|
|
1104
|
+
has_sidecar=layouts_blocks is not None, canvas=canvas)
|
|
1105
|
+
with open(os.path.join(pack, 'design.md'), 'w', encoding='utf-8') as f:
|
|
1106
|
+
f.write(design)
|
|
1107
|
+
if layouts_blocks is not None:
|
|
1108
|
+
with open(os.path.join(pack, 'layouts.md'), 'w', encoding='utf-8') as f:
|
|
1109
|
+
f.write(build_layouts_md(layouts_blocks, canvas))
|
|
1110
|
+
|
|
1111
|
+
# 审计记录写回**抽取工作目录**,不进交付包:包是消费产物,`ref/` 里的东西
|
|
1112
|
+
# 没有任何门禁读、消费模型也用不上,放进去只会让「别读我」和「几 MB 材料」同时下发。
|
|
1113
|
+
ref = os.path.join(stage1, 'ref')
|
|
1114
|
+
os.makedirs(ref, exist_ok=True)
|
|
1115
|
+
audit_path = os.path.join(ref, 'audit.yaml')
|
|
1116
|
+
with open(audit_path, 'w', encoding='utf-8') as f:
|
|
1117
|
+
f.write(render_audit(manifest, extract, audit))
|
|
1118
|
+
carried = []
|
|
1119
|
+
# L 层自备的 ref 补充件(判断理由、版式溯源等)也归到抽取目录
|
|
1120
|
+
l_ref = os.path.join(lout, 'ref')
|
|
1121
|
+
if os.path.isdir(l_ref):
|
|
1122
|
+
shutil.copytree(l_ref, ref, dirs_exist_ok=True)
|
|
1123
|
+
carried.append('(L 层 ref/)')
|
|
1124
|
+
|
|
1125
|
+
with open(os.path.join(pack, 'manifest.json'), 'w', encoding='utf-8') as f:
|
|
1126
|
+
json.dump(build_package_manifest(manifest, consumer, audit, extract, pack),
|
|
1127
|
+
f, ensure_ascii=False, indent=2, sort_keys=True)
|
|
1128
|
+
f.write('\n')
|
|
1129
|
+
|
|
1130
|
+
print('pack → %s' % pack)
|
|
1131
|
+
print(' design.md %d 行 / %.1f KB%s'
|
|
1132
|
+
% (len(design.splitlines()), len(design.encode()) / 1024.0,
|
|
1133
|
+
' layouts.md sidecar' if layouts_blocks is not None else ' (layouts 内联/缺省)'))
|
|
1134
|
+
print(' assets %d 条目 / %d 文件落盘' % (len(consumer), len(copied)))
|
|
1135
|
+
for d in copied:
|
|
1136
|
+
print(' %-52s %8d B' % (os.path.relpath(d, pack), os.path.getsize(d)))
|
|
1137
|
+
print(' 审计记录 -> %s%s(不进交付包)'
|
|
1138
|
+
% (os.path.relpath(audit_path, os.path.dirname(stage1)),
|
|
1139
|
+
' ' + ', '.join(carried) if carried else ''))
|
|
1140
|
+
|
|
1141
|
+
# ---- 数值可追溯机检:产物里每个色值/字号/坐标都得能指回普查值
|
|
1142
|
+
derived_values, derived_tokens = set(), set()
|
|
1143
|
+
for d in manifest.get('derived') or []:
|
|
1144
|
+
if d.get('value'):
|
|
1145
|
+
v = unquote(d['value'])
|
|
1146
|
+
derived_values.add(v.upper() if v.startswith('#') else v)
|
|
1147
|
+
if d.get('token'):
|
|
1148
|
+
derived_tokens.add(unquote(d['token']))
|
|
1149
|
+
try:
|
|
1150
|
+
factor = float(manifest.get('rebase_factor') or 0) or None
|
|
1151
|
+
except ValueError:
|
|
1152
|
+
raise Fail('rebase_factor 不是数字: %r' % manifest.get('rebase_factor'))
|
|
1153
|
+
sp = os.path.join(stage1, 'ref', 'shapes.json')
|
|
1154
|
+
shapes = json.load(open(sp, encoding='utf-8'))['shapes'] if os.path.exists(sp) else []
|
|
1155
|
+
problems, checked = trace_check(pack, extract, shapes,
|
|
1156
|
+
derived_values, derived_tokens, factor)
|
|
1157
|
+
integrity = media_integrity(stage1, extract)
|
|
1158
|
+
if integrity:
|
|
1159
|
+
print('\n--- 媒体产物完整性 ---')
|
|
1160
|
+
for b in integrity:
|
|
1161
|
+
print(' FAIL ' + b)
|
|
1162
|
+
raise Fail('media-out 里的文件和 extract.json 的记录对不上(%d 处)' % len(integrity))
|
|
1163
|
+
print('\n--- 数值可追溯机检 ---')
|
|
1164
|
+
print(' 受检 hex %d / fontSize %d / slot box %d;derived 豁免 %d%s%s'
|
|
1165
|
+
% (checked['hex'], checked['size'], checked['box'], checked['exempt'],
|
|
1166
|
+
';判断键跳过 %d 行(avoid/text_safe/pairing_rule)' % checked['judgement']
|
|
1167
|
+
if checked['judgement'] else '',
|
|
1168
|
+
';rebase_factor=%g' % factor if factor else ''))
|
|
1169
|
+
if not shapes:
|
|
1170
|
+
print(' ⚠ 未找到 %s,slot 坐标一项无法校验' % sp)
|
|
1171
|
+
trace_rc = 0
|
|
1172
|
+
if problems:
|
|
1173
|
+
trace_rc = 1
|
|
1174
|
+
print(' FAIL %d 处数值无法追溯(既不命中普查值,也没在 manifest 的 derived 里声明):'
|
|
1175
|
+
% len(problems))
|
|
1176
|
+
for kind, fname, lineno, val, tip, path in problems:
|
|
1177
|
+
print(' %s:%d %s %s ← %s' % (fname, lineno, kind, val, path))
|
|
1178
|
+
print(' %s' % (tip or ''))
|
|
1179
|
+
print(' 修法二选一:改成命中的值,或在 manifest.yaml 的 derived: 段声明推导理由。')
|
|
1180
|
+
else:
|
|
1181
|
+
print(' PASS 全部可追溯')
|
|
1182
|
+
|
|
1183
|
+
rc = trace_rc
|
|
1184
|
+
print('\n--- check_v2 ---')
|
|
1185
|
+
sys.stdout.flush() # 子进程直写 fd,不 flush 会让本脚本的输出排在其后
|
|
1186
|
+
r = subprocess.run([sys.executable, os.path.join(HERE, 'check_v2.py'), pack])
|
|
1187
|
+
rc = rc or r.returncode
|
|
1188
|
+
if not args.check_v1:
|
|
1189
|
+
args.check_v1 = _autodetect_check_v1()
|
|
1190
|
+
if not args.check_v1:
|
|
1191
|
+
print('check_v1: 未探测到($DSM_V1_DIR / 同级 skill / 开发机路径均无)—— v1 门禁未跑,交付前必须补跑')
|
|
1192
|
+
if args.check_v1:
|
|
1193
|
+
print('\n--- check_v1 ---')
|
|
1194
|
+
sys.stdout.flush()
|
|
1195
|
+
r1 = subprocess.run([sys.executable, args.check_v1,
|
|
1196
|
+
os.path.join(pack, 'design.md'), 'slide'])
|
|
1197
|
+
rc = rc or r1.returncode
|
|
1198
|
+
if rc:
|
|
1199
|
+
print('\n门禁未过(exit %d)。回修属 L 层的事:改判断单后重跑本脚本。' % rc)
|
|
1200
|
+
return rc
|
|
1201
|
+
|
|
1202
|
+
|
|
1203
|
+
if __name__ == '__main__':
|
|
1204
|
+
sys.exit(main())
|