@lark-apaas/coding-steering 0.1.32-beta.0 → 0.1.32-dev.3598eb3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -6
- package/steering/design-html/skills/charts/SKILL.md +4 -0
- package/steering/design-html/skills/pptx-style-extract/SKILL.md +71 -26
- package/steering/design-html/skills/pptx-style-extract/font-fallback.yaml +3 -3
- package/steering/design-html/skills/pptx-style-extract/scripts/census.py +26 -14
- package/steering/design-html/skills/pptx-style-extract/scripts/check_v2.py +153 -8
- package/steering/design-html/skills/pptx-style-extract/scripts/draft.py +2831 -293
- package/steering/design-html/skills/pptx-style-extract/scripts/extract.py +544 -22
- package/steering/design-html/skills/pptx-style-extract/scripts/ooxml.py +19 -2
- package/steering/design-html/skills/pptx-style-extract/scripts/package.py +991 -165
- package/steering/design-html/skills/pptx-style-extract/scripts/parts.py +22 -3
- package/steering/design-html/skills/pptx-style-extract/scripts/query.py +4 -9
- package/steering/design-html/skills/pptx-style-extract/scripts/render_pages.py +20 -12
- package/steering/design-html/skills/pptx-style-extract/scripts/test_asset_judgment_package.py +556 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/test_background_composite.py +364 -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 +76 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/test_flow_layout_contract.py +528 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/test_layout_css.py +1814 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/test_logo_scope.py +600 -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 +315 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/verify_layout_assets.py +421 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/verify_logo_scope.py +12 -0
- package/steering/design-html/skills/pptx-style-extract/v2-format-spec.md +27 -15
- package/steering/design-html/skills/preflight/scripts/probe.sh +0 -0
- package/steering/nestjs-react-fullstack/skills/app-init-feasibility-guide/SKILL.md +1 -0
- package/steering/nestjs-react-fullstack/skills/authn-guide/SKILL.md +6 -0
- package/steering/nestjs-react-fullstack/skills/authz-guide/SKILL.md +5 -5
- package/steering/nestjs-react-fullstack/skills/authz-guide/references/dynamic-permission-guide.md +1 -1
- package/steering/nestjs-react-fullstack/skills/client-builtins-file-storage-service/SKILL.md +37 -113
- package/steering/nestjs-react-fullstack/skills/client-builtins-user-service/SKILL.md +13 -2
- package/steering/nestjs-react-fullstack/skills/code-fix/SKILL.md +7 -7
- package/steering/nestjs-react-fullstack/skills/coding-guide/SKILL.md +149 -24
- package/steering/nestjs-react-fullstack/skills/connections-sdk/SKILL.md +202 -0
- package/steering/nestjs-react-fullstack/skills/nestjs-cache/SKILL.md +255 -0
- package/steering/nestjs-react-fullstack/skills/plugin-guide/SKILL.md +158 -543
- package/steering/nestjs-react-fullstack/skills/plugin-guide/references/plugin-coding-guide.md +15 -1
- package/steering/nestjs-react-fullstack/skills/plugin-guide/references/table.md +30 -14
- package/steering/nestjs-react-fullstack/skills/raw-sql-boundary-audit/SKILL.md +63 -0
- package/steering/nestjs-react-fullstack/skills/server-builtins-file-storage-service/SKILL.md +1 -1
- package/steering/nestjs-react-fullstack/skills_common/trigger-guide/SKILL.md +284 -12
- 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
- package/steering/nestjs-react-fullstack/skills/client-add-aily-web-chat/SKILL.md +0 -139
- package/steering/nestjs-react-fullstack/skills/feishu/SKILL.md +0 -269
- package/steering/nestjs-react-fullstack/skills/feishu/references/approval.md +0 -214
- package/steering/nestjs-react-fullstack/skills/feishu/references/attendance.md +0 -163
- package/steering/nestjs-react-fullstack/skills/feishu/references/bitable.md +0 -311
- package/steering/nestjs-react-fullstack/skills/feishu/references/calendar.md +0 -190
- package/steering/nestjs-react-fullstack/skills/feishu/references/contacts.md +0 -160
- package/steering/nestjs-react-fullstack/skills/feishu/references/doc.md +0 -257
- package/steering/nestjs-react-fullstack/skills/feishu/references/drive.md +0 -104
- package/steering/nestjs-react-fullstack/skills/feishu/references/events.md +0 -199
- package/steering/nestjs-react-fullstack/skills/feishu/references/id-convert.md +0 -128
- package/steering/nestjs-react-fullstack/skills/feishu/references/messaging.md +0 -207
- package/steering/nestjs-react-fullstack/skills/feishu/references/oauth.md +0 -165
- package/steering/nestjs-react-fullstack/skills/feishu/references/perm.md +0 -91
- package/steering/nestjs-react-fullstack/skills/feishu/references/wiki.md +0 -165
- package/steering/nestjs-react-fullstack/skills_common/trigger-guide/references/trigger-lifecycle.md +0 -301
|
@@ -5,26 +5,69 @@
|
|
|
5
5
|
|
|
6
6
|
产出 <stage1-outdir>/l-out/:
|
|
7
7
|
BRIEF.md 唯一必读简报:事实 + 草案依据 + 待判断清单
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
vision-group-*.jpg 候选图与所在页语境拼版
|
|
9
|
+
asset-vision-groups.json 候选来源、位置与预算索引
|
|
10
|
+
manifest.yaml / frontmatter.yaml / layout-controls.yaml / layouts.yaml / body.md
|
|
11
|
+
判断单与坐标事实,可直接进 package.py
|
|
10
12
|
|
|
11
13
|
草案里所有数值都来自 extract.json;凡是需要「像人一样看」才能定的,写成 `TODO:` 行
|
|
12
14
|
(package.py 见 TODO 即 FAIL),由 L 层改掉。
|
|
13
15
|
"""
|
|
14
16
|
import argparse
|
|
17
|
+
import copy
|
|
18
|
+
import glob
|
|
15
19
|
import json
|
|
16
20
|
import os
|
|
17
21
|
import re
|
|
18
22
|
import shutil
|
|
23
|
+
import subprocess
|
|
19
24
|
import sys
|
|
20
25
|
from collections import Counter, defaultdict
|
|
21
26
|
|
|
27
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
28
|
+
from ooxml import OFFICE_DEFAULT_FONTS # noqa: E402
|
|
29
|
+
from census import (ASSET_WARN_SINGLE, FULLSCREEN_COVERAGE, LUM_MID, # noqa: E402
|
|
30
|
+
REPEAT_MIN, SMALL_IMG_W_PCT, canvas_coverage)
|
|
31
|
+
|
|
32
|
+
OPAQUE_ENOUGH = 128 # 能当背景的最低不透明度:低于半透明就遮不住底下的东西,
|
|
33
|
+
# 那是叠加装饰不是背景
|
|
34
|
+
FILL_MANY = 5 # 「被大量当填充铺开」的次数下限,用于区分卡片底与偶发用色
|
|
35
|
+
BG_CONTENT_CAP = 5 # 内容页背景收几张:再多消费端也挑不过来,超出的写进 TODO 交人取舍
|
|
36
|
+
SHEET_BATCH = 12 # 每张联系表最多 12 个候选;候选不截断,超出就继续生成下一张
|
|
37
|
+
CONTEXT_BATCH = 8 # 每张整页语境表最多 8 页;同页只渲染一次
|
|
38
|
+
# 与 studio_server_faas 的批量 vision 图数预算保持一致。单页大组允许 1 张整页图 +
|
|
39
|
+
# 9 张候选;多个小页合组时,所有整页图和候选图合计最多 5 张。
|
|
40
|
+
SINGLE_PAGE_IMAGE_BUDGET = 10
|
|
41
|
+
MULTI_PAGE_IMAGE_BUDGET = 5
|
|
42
|
+
# Skill 侧的总输入上限:限制模型需要读取的拼版数量和总视觉图数,而不是偷偷截断
|
|
43
|
+
# 中间产物。首页、尾页优先;其余超限候选在 gaps 中显式说明。
|
|
44
|
+
VISUAL_PACK_CAP = 5
|
|
45
|
+
VISUAL_INPUT_CAP = 30
|
|
46
|
+
VISUAL_PREVIEW_MAX_EDGE = 1200
|
|
47
|
+
VISUAL_JPEG_QUALITY = 82
|
|
48
|
+
|
|
22
49
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
23
50
|
SKILL_ROOT = os.path.dirname(HERE)
|
|
24
51
|
SYS_FALLBACK = '"PingFang SC", "Microsoft YaHei", sans-serif'
|
|
25
52
|
|
|
26
53
|
|
|
27
54
|
# ---------------------------------------------------------------- 小工具
|
|
55
|
+
# 被名额截掉的东西统一记在这里,最后并进 gaps。截断本身是必要的(色板 40 个 token
|
|
56
|
+
# 消费端挑不过来),但**不说**就成了「悄悄少了东西而产物看起来正常」——消费端会以为
|
|
57
|
+
# 它拿到的就是全部。
|
|
58
|
+
_TRUNCATED = []
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def note_truncation(kind, kept, total, advice='', where=''):
|
|
62
|
+
"""记一条「这里按名额截断了」。kept >= total 时什么都不记。
|
|
63
|
+
|
|
64
|
+
按 kind 归并成一条 gap:同一类截断逐处各写一行会淹掉别的 gaps。
|
|
65
|
+
"""
|
|
66
|
+
if total > kept:
|
|
67
|
+
_TRUNCATED.append((kind, kept, total, advice, where))
|
|
68
|
+
return kept
|
|
69
|
+
|
|
70
|
+
|
|
28
71
|
def hex2rgb(h):
|
|
29
72
|
h = h.lstrip('#')
|
|
30
73
|
return tuple(int(h[i:i + 2], 16) for i in (0, 2, 4))
|
|
@@ -82,7 +125,64 @@ def q(v):
|
|
|
82
125
|
|
|
83
126
|
|
|
84
127
|
# ---------------------------------------------------------------- 颜色
|
|
85
|
-
def
|
|
128
|
+
def bg_colors(d):
|
|
129
|
+
"""页面/版式/母版的 `background` 声明里出现的底色,按声明次数排序。
|
|
130
|
+
|
|
131
|
+
「哪个色是底色」是直读事实(bgPr / bgRef),不用靠亮度猜:渐变里出现的浅色,
|
|
132
|
+
亮度可能比真底色更像底色。
|
|
133
|
+
"""
|
|
134
|
+
cnt = Counter()
|
|
135
|
+
rows = (d.get('slides') or []) + (d.get('layouts') or []) \
|
|
136
|
+
+ ((d.get('masters') or {}).get('masters') or [])
|
|
137
|
+
for row in rows:
|
|
138
|
+
bg = row.get('background')
|
|
139
|
+
if not isinstance(bg, dict):
|
|
140
|
+
continue
|
|
141
|
+
cols = []
|
|
142
|
+
if isinstance(bg.get('color'), dict):
|
|
143
|
+
cols.append(bg['color'])
|
|
144
|
+
for st in (bg.get('stops') or []):
|
|
145
|
+
if isinstance(st.get('color'), dict):
|
|
146
|
+
cols.append(st['color'])
|
|
147
|
+
for c in cols:
|
|
148
|
+
h = (c.get('hex') or '').upper()
|
|
149
|
+
if h:
|
|
150
|
+
cnt[h] += 1
|
|
151
|
+
return [h for h, _ in cnt.most_common()]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _gap_cut(vals, lo, hi):
|
|
155
|
+
"""在排序后的值里找最大间隙,切点取间隙中点。
|
|
156
|
+
|
|
157
|
+
不用中位数:中位数会正好落在某个样本自己身上,它归哪边就只取决于写 >= 还是 >,
|
|
158
|
+
纯属任意。真正的分界在两族之间的空档里。夹在 [lo, hi] 内,避免整套同色的模板
|
|
159
|
+
把界推到极端。
|
|
160
|
+
"""
|
|
161
|
+
v = sorted(vals)
|
|
162
|
+
if len(v) < 2:
|
|
163
|
+
return (lo + hi) / 2.0
|
|
164
|
+
_, mid = max((v[i + 1] - v[i], (v[i + 1] + v[i]) / 2.0) for i in range(len(v) - 1))
|
|
165
|
+
return min(max(mid, lo), hi)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def palette_cuts(rows):
|
|
169
|
+
"""「有彩 vs 中性」「深 vs 浅」的分界,按本模板自己的色分布切。
|
|
170
|
+
|
|
171
|
+
固定分界必然错一边:低饱和的莫兰迪配色整套都在低位,高饱和的品牌配色整套都在高位。
|
|
172
|
+
"""
|
|
173
|
+
sat_cut = _gap_cut([r['sat'] for r in rows], 0.12, 0.45)
|
|
174
|
+
lums = sorted(r['lum'] for r in rows) or [0.0]
|
|
175
|
+
return sat_cut, lums[len(lums) // 2]
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def draft_colors(d, cusage=None):
|
|
179
|
+
"""色板 token:名字按**实际用法**定,不只看亮度饱和度。
|
|
180
|
+
|
|
181
|
+
只看 lum/sat 会把「主要用来填色的纯黑」命名成 ink(文字色)、把「只出现在渐变里的
|
|
182
|
+
浅蓝」命名成 surface-alt。这里先看它在形状上主要干什么,再结合
|
|
183
|
+
亮度定名;用量太少的直接不进色板。
|
|
184
|
+
"""
|
|
185
|
+
cusage = cusage or {}
|
|
86
186
|
pool = [c for c in d['color_freq']
|
|
87
187
|
if c.get('class') == 'design' and abs((c.get('alpha') or 100) - 100) < 0.1]
|
|
88
188
|
seen, rows = set(), []
|
|
@@ -92,29 +192,63 @@ def draft_colors(d):
|
|
|
92
192
|
continue
|
|
93
193
|
seen.add(h)
|
|
94
194
|
rgb = hex2rgb(h)
|
|
95
|
-
|
|
96
|
-
|
|
195
|
+
u = cusage.get(h) or Counter()
|
|
196
|
+
tot = sum(u.values())
|
|
197
|
+
main = u.most_common(1)[0][0] if tot else None
|
|
198
|
+
rows.append({'hex': h, 'n': c['n'], 'lum': lum(rgb), 'sat': satu(rgb),
|
|
199
|
+
'use': u, 'use_n': tot, 'main': main})
|
|
200
|
+
# 用量只用来**命名**,不作准入门槛——color_usage 只数形状级的填充/描边/文字,
|
|
201
|
+
# 背景 p:bg 与主题色不在其中,拿它筛会把色板砍到只剩极少数几个。
|
|
202
|
+
strong = sorted(rows, key=lambda r: -r['n'])
|
|
203
|
+
|
|
204
|
+
SAT_CUT, LUM_CUT = palette_cuts(rows)
|
|
97
205
|
|
|
98
206
|
tokens, used = [], set()
|
|
99
207
|
|
|
100
208
|
def take(pred, names):
|
|
101
209
|
for name in names:
|
|
102
|
-
for r in
|
|
210
|
+
for r in strong:
|
|
103
211
|
if r['hex'] in used or not pred(r):
|
|
104
212
|
continue
|
|
105
213
|
used.add(r['hex'])
|
|
106
214
|
tokens.append((name, r))
|
|
107
215
|
break
|
|
108
216
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
217
|
+
def kind(r):
|
|
218
|
+
if r['main'] == '文字':
|
|
219
|
+
return 'text'
|
|
220
|
+
if r['main'] in ('填充', '渐变', '描边'):
|
|
221
|
+
return 'paint'
|
|
222
|
+
return 'unknown' # 形状层看不到用法,退回亮度/饱和度判断
|
|
223
|
+
|
|
224
|
+
# 墨色:主要用来写字(或看不出用法但本身是深中性色),且不是彩色
|
|
225
|
+
take(lambda r: r['sat'] < SAT_CUT and r['lum'] < min(LUM_CUT, LUM_MID)
|
|
226
|
+
and (kind(r) == 'text' or kind(r) == 'unknown'), ['ink', 'ink-muted'])
|
|
227
|
+
# 底色:直接取页面 background 声明里的色,按声明次数排
|
|
228
|
+
grounds = bg_colors(d)
|
|
229
|
+
for name in ('surface', 'surface-alt'):
|
|
230
|
+
for h in grounds:
|
|
231
|
+
r = next((x for x in strong if x['hex'] == h and x['hex'] not in used), None)
|
|
232
|
+
if r:
|
|
233
|
+
used.add(r['hex'])
|
|
234
|
+
tokens.append((name, r))
|
|
235
|
+
break
|
|
236
|
+
|
|
237
|
+
# 卡片/面板底:页面底色之外,真被大量当填充铺开的浅色(≥5 处才算)
|
|
238
|
+
take(lambda r: r['lum'] > max(LUM_CUT, 0.85) and r['sat'] < SAT_CUT
|
|
239
|
+
and (r['use'].get('填充') or 0) >= FILL_MANY, ['surface-raised'])
|
|
240
|
+
# 表达色:有彩度的按频次排
|
|
241
|
+
take(lambda r: r['sat'] >= SAT_CUT, ['primary', 'accent', 'accent-2', 'accent-3'])
|
|
242
|
+
# 其余低饱和色一律 neutral-N——它到底是卡片底、分隔线还是描边,数据分不出来,
|
|
243
|
+
# 就不要用名字去替消费方下结论;真实用法写在 Colors 表的用途列里。
|
|
244
|
+
take(lambda r: r['sat'] < SAT_CUT, ['neutral', 'neutral-2', 'neutral-3'])
|
|
245
|
+
spare = [r for r in rows if r['hex'] not in used]
|
|
246
|
+
note_truncation('设计色', 6, len(spare), '色板只收主要色,其余在联系表里看')
|
|
247
|
+
rest = spare[:6]
|
|
115
248
|
return tokens, rest, rows
|
|
116
249
|
|
|
117
250
|
|
|
251
|
+
|
|
118
252
|
# ---------------------------------------------------------------- 字体
|
|
119
253
|
def parse_fallback_table():
|
|
120
254
|
path = os.path.join(SKILL_ROOT, 'font-fallback.yaml')
|
|
@@ -142,6 +276,48 @@ def norm(s):
|
|
|
142
276
|
return re.sub(r'[\s\-_]', '', s or '').lower()
|
|
143
277
|
|
|
144
278
|
|
|
279
|
+
OFFICE_DEFAULT_FONTS_NORM = {norm(x) for x in OFFICE_DEFAULT_FONTS}
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def cover_slot_colors(tokens, archetypes, rows, cusage):
|
|
283
|
+
"""slot CSS 里出现的每个色值都必须在色板里有名字。
|
|
284
|
+
|
|
285
|
+
Hard Rules 写「颜色只用 colors 里的 token」,而 slot CSS 的 color 是从模板直读的,
|
|
286
|
+
两者不对齐就等于产物自己违反自己的规则——slot 的色值直读自模板,未必都已进
|
|
287
|
+
色板。这里把缺的补进色板,按用法归族命名。
|
|
288
|
+
"""
|
|
289
|
+
have = {r['hex'].upper() for _, r in tokens}
|
|
290
|
+
by_hex = {r['hex'].upper(): r for r in rows}
|
|
291
|
+
sat_cut, lum_cut = palette_cuts(rows) # 与 draft_colors 同一套切点,别各切各的
|
|
292
|
+
used = [n for n, _ in tokens]
|
|
293
|
+
|
|
294
|
+
def nxt(fam):
|
|
295
|
+
if fam not in used:
|
|
296
|
+
return fam
|
|
297
|
+
i = 2
|
|
298
|
+
while '%s-%d' % (fam, i) in used:
|
|
299
|
+
i += 1
|
|
300
|
+
return '%s-%d' % (fam, i)
|
|
301
|
+
|
|
302
|
+
added = []
|
|
303
|
+
for a in archetypes:
|
|
304
|
+
for s in a['slots']:
|
|
305
|
+
h = (s.get('_color') or '').upper()
|
|
306
|
+
if not h.startswith('#') or h in have:
|
|
307
|
+
continue
|
|
308
|
+
have.add(h)
|
|
309
|
+
r = by_hex.get(h)
|
|
310
|
+
if r is None: # 普查里没有这个色(理论上不该发生),跳过不编造
|
|
311
|
+
continue
|
|
312
|
+
fam = ('ink' if r['sat'] < sat_cut and r['lum'] < min(lum_cut, LUM_MID)
|
|
313
|
+
else 'accent' if r['sat'] >= sat_cut else 'neutral')
|
|
314
|
+
name = nxt(fam)
|
|
315
|
+
used.append(name)
|
|
316
|
+
tokens.append((name, r))
|
|
317
|
+
added.append((name, h))
|
|
318
|
+
return added
|
|
319
|
+
|
|
320
|
+
|
|
145
321
|
def draft_fonts(d):
|
|
146
322
|
table = parse_fallback_table()
|
|
147
323
|
groups = defaultdict(lambda: {'rendered': 0, 'weights': set(), 'names': set(), 'bold': 0})
|
|
@@ -167,6 +343,7 @@ def draft_fonts(d):
|
|
|
167
343
|
return None
|
|
168
344
|
|
|
169
345
|
out = []
|
|
346
|
+
note_truncation('字族', 4, len(ranked), '只报渲染量最大的几族')
|
|
170
347
|
for key, g in ranked[:4]:
|
|
171
348
|
fam = resolve(sorted(g['names'], key=len))
|
|
172
349
|
stack = [sorted(g['names'], key=len)[0]]
|
|
@@ -201,35 +378,193 @@ def import_line(fonts):
|
|
|
201
378
|
return "@import url('%s?%s&display=swap');" % (MIRROR, fam), webs
|
|
202
379
|
|
|
203
380
|
|
|
381
|
+
def quant(hit, total):
|
|
382
|
+
"""覆盖率决定量词——不到一半就不许说「一律/每页」。"""
|
|
383
|
+
if not total:
|
|
384
|
+
return None
|
|
385
|
+
r = hit / float(total)
|
|
386
|
+
if r >= 0.9:
|
|
387
|
+
return '一律'
|
|
388
|
+
if r >= 0.5:
|
|
389
|
+
return '多数'
|
|
390
|
+
return None
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def color_usage(shapes, d=None):
|
|
394
|
+
"""每个色值在形状上的真实用法计数:填充 / 渐变 / 描边 / 文字。
|
|
395
|
+
|
|
396
|
+
用途列不能靠预设字典猜——同一个色在不同模板里的主用途完全不同。这里从 shapes
|
|
397
|
+
直接数,数不到就如实说数不到。
|
|
398
|
+
"""
|
|
399
|
+
def hx(c):
|
|
400
|
+
return (c.get('hex') or '').upper() if isinstance(c, dict) else ''
|
|
401
|
+
|
|
402
|
+
def walk_text_colors(node, out):
|
|
403
|
+
"""文本样式可能嵌在 lstStyle.lvlNpPr / defRPr / rPr 任一层——通用遍历,
|
|
404
|
+
别逐层枚举(枚举漏过 lvl2pPr,导致主色被写成「用途待确认」)。"""
|
|
405
|
+
if isinstance(node, dict):
|
|
406
|
+
if isinstance(node.get('color'), dict) and hx(node['color']):
|
|
407
|
+
out.append(hx(node['color']))
|
|
408
|
+
for v in node.values():
|
|
409
|
+
walk_text_colors(v, out)
|
|
410
|
+
elif isinstance(node, list):
|
|
411
|
+
for v in node:
|
|
412
|
+
walk_text_colors(v, out)
|
|
413
|
+
|
|
414
|
+
use = defaultdict(Counter)
|
|
415
|
+
for s in shapes:
|
|
416
|
+
f = s.get('fill') or {}
|
|
417
|
+
if f.get('type') == 'solid' and hx(f.get('color')):
|
|
418
|
+
use[hx(f['color'])]['填充'] += 1
|
|
419
|
+
for st in (f.get('stops') or []):
|
|
420
|
+
if hx(st.get('color')):
|
|
421
|
+
use[hx(st['color'])]['渐变'] += 1
|
|
422
|
+
ln = s.get('line') or {}
|
|
423
|
+
if hx(ln.get('color')):
|
|
424
|
+
use[hx(ln['color'])]['描边'] += 1
|
|
425
|
+
cols = []
|
|
426
|
+
walk_text_colors(s.get('text') or {}, cols)
|
|
427
|
+
for h in cols:
|
|
428
|
+
use[h]['文字'] += 1
|
|
429
|
+
for h in bg_colors(d or {}):
|
|
430
|
+
use[h]['页面背景'] += 1
|
|
431
|
+
# 主题 clrScheme:这类色常常只在主题里声明、页面上由 schemeClr 间接引用,
|
|
432
|
+
# 不记上就会在用途列写「未落在形状上」,看着像没人用。
|
|
433
|
+
for th in ((d or {}).get('themes') or []):
|
|
434
|
+
if not th.get('picked'):
|
|
435
|
+
continue
|
|
436
|
+
for slot, hexv in (th.get('clrScheme') or {}).items():
|
|
437
|
+
if isinstance(hexv, str) and hexv.startswith('#'):
|
|
438
|
+
use[hexv.upper()]['主题 ' + slot] += 1
|
|
439
|
+
return use
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def usage_phrase(counter):
|
|
443
|
+
"""把用法计数写成一句话;主用法占六成以上就直接点名,否则并列前三。"""
|
|
444
|
+
if not counter:
|
|
445
|
+
return '普查里有声明,但未落在形状/背景/主题色上——用途待确认'
|
|
446
|
+
items = counter.most_common()
|
|
447
|
+
tot = sum(counter.values())
|
|
448
|
+
if items[0][1] >= tot * 0.6:
|
|
449
|
+
return '主要作%s(%d/%d 处)' % (items[0][0], items[0][1], tot)
|
|
450
|
+
return '、'.join('%s %d 处' % (k, v) for k, v in items[:3])
|
|
451
|
+
|
|
452
|
+
|
|
204
453
|
def draft_anchors(d, tokens, fonts, roles, assets, archetypes):
|
|
205
|
-
|
|
454
|
+
"""anchors 只报测到的数,不下「这套风格是什么」的结论。
|
|
455
|
+
|
|
456
|
+
这一段在 design.md 里读起来像「设计总纲」,消费端会照它建全局样式。脚本写进去的
|
|
457
|
+
每一句解读都会被当成规则执行——实测把 1/8 覆盖率的 logo 描述成「跨页不动」,
|
|
458
|
+
消费端就建了全局 CSS 类,12 页全铺了 logo。所以这里只给覆盖率和计数,
|
|
459
|
+
「这是不是这套风格的特征」由看得到图的人判断。
|
|
460
|
+
"""
|
|
461
|
+
A = []
|
|
462
|
+
n_arch = len(archetypes) or 1
|
|
463
|
+
|
|
464
|
+
# 1. 表达色:未取用的高频彩色要如实带上,不能说「其余全是中性」
|
|
465
|
+
names = [x[0] for x in tokens]
|
|
206
466
|
chroma = [n for n in names if n.startswith(('primary', 'accent'))]
|
|
467
|
+
if chroma:
|
|
468
|
+
A.append((chroma[0] + '-led-palette', 'token',
|
|
469
|
+
'有彩色 token 共 %d 个,用量最大的是 %s'
|
|
470
|
+
% (len(chroma), '、'.join(chroma[:3]))))
|
|
471
|
+
|
|
472
|
+
# 2. 圆角:按普查占比
|
|
207
473
|
radii = d.get('radii_census') or []
|
|
208
474
|
zero = next((r for r in radii if r['px'] == 0), None)
|
|
209
|
-
|
|
210
|
-
|
|
475
|
+
tot_r = sum(r['n'] for r in radii) or 1
|
|
476
|
+
if zero:
|
|
477
|
+
q0 = quant(zero['n'], tot_r)
|
|
478
|
+
if q0:
|
|
479
|
+
A.append(('zero-radius', 'token',
|
|
480
|
+
'圆角量为零的形状占 %d%%(普查 %d 个带圆角声明的形状)'
|
|
481
|
+
% (round(100.0 * zero['n'] / tot_r), tot_r)))
|
|
482
|
+
|
|
483
|
+
# 3. 满屏底图:按有背景的页型占比
|
|
484
|
+
with_bg = sum(1 for a in archetypes if a.get('bg'))
|
|
485
|
+
qb = quant(with_bg, n_arch)
|
|
486
|
+
if qb:
|
|
487
|
+
A.append(('full-bleed-ground', 'pattern',
|
|
488
|
+
'%d/%d 个页型声明了整幅铺满的底图' % (with_bg, n_arch)))
|
|
489
|
+
|
|
490
|
+
# 4. 标识:位置是不是真的固定,看有几个不同的 box
|
|
491
|
+
logo_slots = [s for a in archetypes for s in a['slots']
|
|
492
|
+
if str(s.get('asset') or '').startswith(('logo', 'slogan'))]
|
|
493
|
+
logo_arch = sum(1 for a in archetypes
|
|
494
|
+
if any(str(s.get('asset') or '').startswith(('logo', 'slogan'))
|
|
495
|
+
for s in a['slots']))
|
|
496
|
+
boxes = {tuple(s['box']) for s in logo_slots}
|
|
497
|
+
# anchors 是「这套风格的定义性特征」,消费端读它来建全局样式。只在少数页型出现的
|
|
498
|
+
# 东西写进来,等于宣布它是全局元素——实测某模板 logo 只在 1/8 个页型上,anchor 仍
|
|
499
|
+
# 写成「跨页不动」,消费端据此建了个全局 CSS 类,12 页全铺了 logo。
|
|
500
|
+
# 所以这里和其他 anchor 用同一把尺:覆盖率不过半就不进 anchors。
|
|
501
|
+
ql = quant(logo_arch, n_arch)
|
|
502
|
+
if logo_arch and len(boxes) == 1 and ql:
|
|
503
|
+
A.append(('corner-locked-logo', 'component',
|
|
504
|
+
'品牌标识出现在 %d/%d 个页型上,这些页型里它的 box 完全一致'
|
|
505
|
+
% (logo_arch, n_arch)))
|
|
506
|
+
elif len(boxes) > 1:
|
|
507
|
+
A.append(('logo-moves-by-archetype', 'component',
|
|
508
|
+
'品牌标识按页型换位换尺寸(共 %d 种摆法),必须按 layouts 里该页型的 box 放,'
|
|
509
|
+
'不能沿用上一页' % len(boxes)))
|
|
510
|
+
|
|
511
|
+
# 5. 渐变:按普查计数
|
|
512
|
+
if (d.get('geom_census') or {}).get('gradient_fills'):
|
|
513
|
+
A.append(('gradient-accent', 'pattern',
|
|
514
|
+
'全档共 %d 处渐变填充' % (d['geom_census']['gradient_fills'])))
|
|
515
|
+
|
|
516
|
+
# 6. 层级:字号跨度 + 字重是否单一(字重真单一才敢说「不靠字重」)
|
|
517
|
+
disp, body = roles.get('display'), roles.get('body')
|
|
518
|
+
if disp and body and disp['sz_px'] > body['sz_px']:
|
|
519
|
+
ws = {s.get('_font_weight') for a in archetypes for s in a['slots']
|
|
520
|
+
if s.get('_font_weight')}
|
|
521
|
+
tail = (',字重只用 %s 一档' % list(ws)[0]) if len(ws) == 1 else ''
|
|
522
|
+
A.append(('size-driven-hierarchy', 'pattern',
|
|
523
|
+
'最大字号档与正文档相差 %.1f 倍(见 typography)%s'
|
|
524
|
+
% (disp['sz_px'] / body['sz_px'], tail)))
|
|
525
|
+
|
|
526
|
+
# 7. 阴影:只在描边极少时才敢说「不用描边分隔」
|
|
211
527
|
eff = d.get('effects_census') or {}
|
|
212
|
-
A = []
|
|
213
|
-
if chroma:
|
|
214
|
-
A.append((chroma[0] + '-led-palette', 'token',
|
|
215
|
-
'表达色只有 %s 这一组,其余全是中性底与墨色,见 colors' % '、'.join(chroma[:3])))
|
|
216
|
-
if zero and zero['n'] >= total_r * 0.7:
|
|
217
|
-
A.append(('zero-radius', 'token', '卡片、按钮、面板一律直角,圆角量在全 deck 压倒性为零'))
|
|
218
|
-
if any(a['kind'] == 'background' for a in assets):
|
|
219
|
-
A.append(('full-bleed-ground', 'pattern', '每页由整幅铺满的底图打底,元素浮在图上而不是浮在纯色块上'))
|
|
220
|
-
if any(a['kind'] == 'logo' for a in assets):
|
|
221
|
-
A.append(('corner-locked-logo', 'component', '品牌标识固定在同一角位,跨页不移动、不缩放'))
|
|
222
|
-
if geom.get('gradient_fills'):
|
|
223
|
-
A.append(('gradient-accent', 'pattern', '强调元素靠线性渐变承载,而不是纯色块'))
|
|
224
|
-
if len(fonts) >= 2:
|
|
225
|
-
A.append(('dual-family-typesetting', 'token', '中文与拉丁数字分属两套字族,同一行里混排'))
|
|
226
528
|
if eff.get('outerShdw'):
|
|
227
|
-
A.append(('soft-shadow-card', 'component',
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
529
|
+
A.append(('soft-shadow-card', 'component',
|
|
530
|
+
'全档 %d 处 outerShdw 外阴影' % eff['outerShdw']))
|
|
531
|
+
|
|
532
|
+
# 8. 双字族:只陈述分工存在,不断言「同一行混排」(普查没采集混排)
|
|
533
|
+
tot_r_font = sum(f['rendered'] for f in fonts) or 1
|
|
534
|
+
if len(fonts) >= 2 and fonts[1]['rendered']:
|
|
535
|
+
A.append(('dual-family-typesetting', 'token',
|
|
536
|
+
'用了两套字族:%s 渲染 %d 处、%s 渲染 %d 处'
|
|
537
|
+
% (fonts[0]['names'][0], fonts[0]['rendered'],
|
|
538
|
+
fonts[1]['names'][0], fonts[1]['rendered'])))
|
|
539
|
+
|
|
540
|
+
# 9. 安全区:只在各页型正文左边界真的收敛时才写
|
|
541
|
+
# 「多宽算正文槽」按本包自己的槽宽分布定:固定 px 门槛在窄版心模板上会一个都不剩
|
|
542
|
+
widths = sorted(s['box'][2] for a in archetypes for s in a['slots'] if not s.get('asset'))
|
|
543
|
+
w_cut = widths[len(widths) // 2] if widths else 0
|
|
544
|
+
lefts = [s['box'][0] for a in archetypes for s in a['slots']
|
|
545
|
+
if not s.get('asset') and s['box'][2] >= w_cut]
|
|
546
|
+
if len(lefts) >= 4:
|
|
547
|
+
common = Counter(lefts).most_common(1)[0]
|
|
548
|
+
qs = quant(common[1], len(lefts))
|
|
549
|
+
if qs:
|
|
550
|
+
A.append(('shared-left-margin', 'token',
|
|
551
|
+
'%d/%d 个正文槽的左边界落在同一个 x 上(坐标见 layouts)'
|
|
552
|
+
% (common[1], len(lefts))))
|
|
553
|
+
|
|
554
|
+
# 10. 双主题:直读事实
|
|
555
|
+
themes = (d.get('theme_topology') or {}).get('themes') or []
|
|
556
|
+
if len(themes) > 1:
|
|
557
|
+
A.append(('dual-theme-masters', 'token',
|
|
558
|
+
'模板声明了 %s 两套主题母版' % ' / '.join(themes)))
|
|
559
|
+
|
|
560
|
+
# 11. 画布:直读事实(兜底凑数也只用真事实)
|
|
561
|
+
cv = d['canvas']['px']
|
|
562
|
+
A.append(('fixed-canvas', 'token',
|
|
563
|
+
'画布 %d×%d,layouts 里的坐标都是这张画布上的绝对像素' % (cv[0], cv[1])))
|
|
564
|
+
if len(archetypes) >= 3:
|
|
565
|
+
A.append(('archetype-catalog', 'pattern',
|
|
566
|
+
'归纳出 %d 种页型' % len(archetypes)))
|
|
567
|
+
|
|
233
568
|
seen, out = set(), []
|
|
234
569
|
for a in A:
|
|
235
570
|
if a[0] in seen:
|
|
@@ -238,8 +573,6 @@ def draft_anchors(d, tokens, fonts, roles, assets, archetypes):
|
|
|
238
573
|
out.append(a)
|
|
239
574
|
return out[:8]
|
|
240
575
|
|
|
241
|
-
|
|
242
|
-
# ---------------------------------------------------------------- 字号轴
|
|
243
576
|
def draft_scale(d, archetypes=()):
|
|
244
577
|
ts = [t for t in d['text_scale'] if t['sz_px'] >= 10]
|
|
245
578
|
ts.sort(key=lambda t: -t['sz_px'])
|
|
@@ -251,8 +584,9 @@ def draft_scale(d, archetypes=()):
|
|
|
251
584
|
display = by_px.get(max(title_sz)) if title_sz else None
|
|
252
585
|
big = [t for t in ts if t['n'] >= 2] or ts
|
|
253
586
|
display = display or big[0]
|
|
254
|
-
|
|
255
|
-
|
|
587
|
+
# 正文档 = 渲染次数最多的那一档。不设「多大算正文」的上限:大字号排版的模板
|
|
588
|
+
# 正文本来就可能比别的模板的标题还大,预设上限会把它整档判错。
|
|
589
|
+
body = max([t for t in ts if t is not display] or ts, key=lambda t: t['n'])
|
|
256
590
|
heading_pool = [t for t in ts if body['sz_px'] * 1.3 <= t['sz_px'] < display['sz_px']]
|
|
257
591
|
heading = max(heading_pool, key=lambda t: t['n']) if heading_pool else None
|
|
258
592
|
small_pool = [t for t in ts if t['sz_px'] < body['sz_px']]
|
|
@@ -279,7 +613,13 @@ def lh_of(t):
|
|
|
279
613
|
|
|
280
614
|
# ---------------------------------------------------------------- 资产
|
|
281
615
|
def probe_image(path):
|
|
282
|
-
info = {
|
|
616
|
+
info = {
|
|
617
|
+
'w': None,
|
|
618
|
+
'h': None,
|
|
619
|
+
'alpha_mean': None,
|
|
620
|
+
'near_blank': False,
|
|
621
|
+
'near_white_ratio': None,
|
|
622
|
+
}
|
|
283
623
|
try:
|
|
284
624
|
from PIL import Image
|
|
285
625
|
except Exception:
|
|
@@ -287,15 +627,116 @@ def probe_image(path):
|
|
|
287
627
|
try:
|
|
288
628
|
im = Image.open(path)
|
|
289
629
|
info['w'], info['h'] = im.size
|
|
630
|
+
preview = im.convert('RGBA').resize((64, 64))
|
|
631
|
+
pixels = (preview.get_flattened_data()
|
|
632
|
+
if hasattr(preview, 'get_flattened_data') else preview.getdata())
|
|
633
|
+
px = list(pixels)
|
|
634
|
+
alpha = [item[3] for item in px]
|
|
290
635
|
if im.mode in ('RGBA', 'LA') or 'transparency' in im.info:
|
|
291
|
-
|
|
292
|
-
info['alpha_mean'] = sum(px) / len(px)
|
|
636
|
+
info['alpha_mean'] = sum(alpha) / len(alpha)
|
|
293
637
|
info['near_blank'] = info['alpha_mean'] < 13 # <5% 不透明度
|
|
638
|
+
visible = [item for item in px if item[3] >= 32]
|
|
639
|
+
if visible:
|
|
640
|
+
near_white = [item for item in visible
|
|
641
|
+
if item[0] >= 235 and item[1] >= 235 and item[2] >= 235]
|
|
642
|
+
info['near_white_ratio'] = round(len(near_white) / float(len(visible)), 3)
|
|
294
643
|
except Exception:
|
|
295
644
|
pass
|
|
296
645
|
return info
|
|
297
646
|
|
|
298
647
|
|
|
648
|
+
def needs_asset_judgment(candidate):
|
|
649
|
+
"""局部图和半透明满屏叠加层需要看图定性;不透明满屏图按背景处理。"""
|
|
650
|
+
effective_alpha = candidate.get('effective_alpha_mean')
|
|
651
|
+
if not candidate.get('fullscreen'):
|
|
652
|
+
return True
|
|
653
|
+
alpha = (effective_alpha if effective_alpha is not None
|
|
654
|
+
else (candidate.get('probe') or {}).get('alpha_mean'))
|
|
655
|
+
return alpha is not None and 13 <= alpha < OPAQUE_ENOUGH
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def fullscreen_effective_alpha(data, outdir, shapes):
|
|
659
|
+
"""满屏图片的实际平均 alpha,包含图片文件 alpha 与 OOXML 形状透明度。"""
|
|
660
|
+
media_out = {row.get('media'): row.get('out') for row in data.get('media') or []
|
|
661
|
+
if row.get('media') and row.get('out')}
|
|
662
|
+
probed = {}
|
|
663
|
+
effective = {}
|
|
664
|
+
for shape in shapes:
|
|
665
|
+
media = shape.get('media')
|
|
666
|
+
if (shape.get('kind') != 'pic' or not media
|
|
667
|
+
or shape.get('w_pct', 0) < 95 or shape.get('h_pct', 0) < 95):
|
|
668
|
+
continue
|
|
669
|
+
if media not in probed:
|
|
670
|
+
out = media_out.get(media)
|
|
671
|
+
probe = probe_image(os.path.join(outdir, out)) if out else {}
|
|
672
|
+
probed[media] = probe.get('alpha_mean')
|
|
673
|
+
source_alpha = probed[media]
|
|
674
|
+
if source_alpha is None:
|
|
675
|
+
source_alpha = 255.0
|
|
676
|
+
try:
|
|
677
|
+
opacity = float(shape.get('opacity', 1.0))
|
|
678
|
+
except (TypeError, ValueError):
|
|
679
|
+
opacity = 1.0
|
|
680
|
+
alpha = source_alpha * max(0.0, min(opacity, 1.0))
|
|
681
|
+
effective[media] = min(effective.get(media, 255.0), alpha)
|
|
682
|
+
return effective
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def fullscreen_overlay_media(data, outdir, shapes):
|
|
686
|
+
"""需要模型判断的满屏叠加层媒体。"""
|
|
687
|
+
return {
|
|
688
|
+
media for media, alpha in fullscreen_effective_alpha(data, outdir, shapes).items()
|
|
689
|
+
if 13 <= alpha < OPAQUE_ENOUGH
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def bg_busy_map(path, canvas, cells=12):
|
|
694
|
+
"""把背景图切成网格,报每格的**局部对比度**(该格内亮度极差)。
|
|
695
|
+
|
|
696
|
+
「哪里不能压文字」的本质是「哪里花」。整幅渐变的底图各格对比度都低,说明没有
|
|
697
|
+
视觉主体;有山峰、人物、产品图的底图会在主体处出现明显更高的对比度。这里只出
|
|
698
|
+
客观数值和一个据此推出的草案,最终由看得到图的人定。
|
|
699
|
+
"""
|
|
700
|
+
try:
|
|
701
|
+
from PIL import Image
|
|
702
|
+
except Exception:
|
|
703
|
+
return None
|
|
704
|
+
try:
|
|
705
|
+
im = Image.open(path).convert('L').resize((cells * 8, cells * 8))
|
|
706
|
+
except Exception:
|
|
707
|
+
return None
|
|
708
|
+
px = im.load()
|
|
709
|
+
grid = []
|
|
710
|
+
for gy in range(cells):
|
|
711
|
+
row = []
|
|
712
|
+
for gx in range(cells):
|
|
713
|
+
vals = [px[gx * 8 + x, gy * 8 + y] for y in range(8) for x in range(8)]
|
|
714
|
+
row.append(max(vals) - min(vals))
|
|
715
|
+
grid.append(row)
|
|
716
|
+
flat = sorted(v for row in grid for v in row)
|
|
717
|
+
if not flat:
|
|
718
|
+
return None
|
|
719
|
+
med = flat[len(flat) // 2]
|
|
720
|
+
hi = flat[int(len(flat) * 0.9)]
|
|
721
|
+
# 主体 = 对比度显著高于全图中位数的连片格子。阈值取「中位数与九分位的中点」,
|
|
722
|
+
# 由本图自己的分布定,不用固定值。
|
|
723
|
+
cut = (med + hi) / 2.0
|
|
724
|
+
cW, cH = canvas
|
|
725
|
+
hot = [(gx, gy) for gy in range(cells) for gx in range(cells) if grid[gy][gx] > cut]
|
|
726
|
+
if not hot:
|
|
727
|
+
return {'busy': None, 'median': med, 'p90': hi, 'why': '各处对比度一致,没有更花的区域'}
|
|
728
|
+
xs = [g[0] for g in hot]
|
|
729
|
+
ys = [g[1] for g in hot]
|
|
730
|
+
span = ((max(xs) - min(xs) + 1) * (max(ys) - min(ys) + 1)) / float(cells * cells)
|
|
731
|
+
if span > 0.5:
|
|
732
|
+
# 热格散落全图,外接矩形几乎覆盖整幅——圈出来等于没圈
|
|
733
|
+
return {'busy': None, 'median': med, 'p90': hi, 'why': '较花的格子散布全图,圈不出单一主体'}
|
|
734
|
+
box = [round(min(xs) * cW / cells), round(min(ys) * cH / cells),
|
|
735
|
+
round((max(xs) - min(xs) + 1) * cW / cells),
|
|
736
|
+
round((max(ys) - min(ys) + 1) * cH / cells)]
|
|
737
|
+
return {'busy': box, 'median': med, 'p90': hi, 'span': round(span, 2)}
|
|
738
|
+
|
|
739
|
+
|
|
299
740
|
def copy_logo_candidates(outdir, logo_pool):
|
|
300
741
|
if not logo_pool:
|
|
301
742
|
return []
|
|
@@ -326,7 +767,8 @@ def copy_logo_candidates(outdir, logo_pool):
|
|
|
326
767
|
return rows
|
|
327
768
|
|
|
328
769
|
|
|
329
|
-
def draft_assets(d, outdir, bg_needed=(), cover_media=None, bg_under=None
|
|
770
|
+
def draft_assets(d, outdir, bg_needed=(), cover_media=None, bg_under=None,
|
|
771
|
+
effective_alpha=None):
|
|
330
772
|
imgs = {i['media']: i for i in d['images']}
|
|
331
773
|
cluster_of = {}
|
|
332
774
|
for c in d.get('media_clusters', []):
|
|
@@ -343,15 +785,35 @@ def draft_assets(d, outdir, bg_needed=(), cover_media=None, bg_under=None):
|
|
|
343
785
|
boxes = img.get('boxes') or []
|
|
344
786
|
top = max(boxes, key=lambda b: b.get('count', 0)) if boxes else {}
|
|
345
787
|
parts = top.get('parts') or []
|
|
346
|
-
|
|
788
|
+
placements, seen_placements = [], set()
|
|
789
|
+
for cluster in boxes:
|
|
790
|
+
box = cluster.get('box') or {}
|
|
791
|
+
rounded = [round(box.get(key, 0)) for key in ('x', 'y', 'w', 'h')]
|
|
792
|
+
for part in cluster.get('parts') or []:
|
|
793
|
+
if '/slides/' in part:
|
|
794
|
+
row = {'slide': slide_no(part), 'box': rounded}
|
|
795
|
+
elif '/slideLayouts/' in part:
|
|
796
|
+
row = {'layout': os.path.basename(part), 'box': rounded}
|
|
797
|
+
else:
|
|
798
|
+
continue
|
|
799
|
+
key = (row.get('slide'), row.get('layout'), tuple(rounded))
|
|
800
|
+
if key not in seen_placements:
|
|
801
|
+
seen_placements.add(key)
|
|
802
|
+
placements.append(row)
|
|
803
|
+
placements.sort(key=lambda row: (
|
|
804
|
+
row.get('slide', 9999), row.get('layout', ''), tuple(row['box'])))
|
|
805
|
+
slides = sorted({row['slide'] for row in placements if row.get('slide')})
|
|
347
806
|
cands.append({
|
|
348
807
|
'media': m['media'], 'file': os.path.basename(out_rel), 'out': out_rel,
|
|
349
808
|
'bytes': m.get('bytes'), 'n': img.get('n', m.get('used_n', 0)),
|
|
809
|
+
'has_compressed': bool(m.get('compressed_out')),
|
|
350
810
|
'fullscreen': bool(img.get('fullscreen')), 'w_pct': img.get('max_w_pct', 0),
|
|
351
811
|
'box': top.get('box') or {}, 'slides': slides,
|
|
812
|
+
'placements': placements,
|
|
352
813
|
'layer_only': bool(parts) and not slides,
|
|
353
814
|
'repeat': bool(img.get('repeat_fixed')),
|
|
354
815
|
'cluster': cluster_of.get(m['media']),
|
|
816
|
+
'effective_alpha_mean': (effective_alpha or {}).get(m['media']),
|
|
355
817
|
'probe': probe, 'reasons': m.get('reasons', []),
|
|
356
818
|
})
|
|
357
819
|
|
|
@@ -362,35 +824,62 @@ def draft_assets(d, outdir, bg_needed=(), cover_media=None, bg_under=None):
|
|
|
362
824
|
if k not in best_of or c['n'] > best_of[k]['n']:
|
|
363
825
|
best_of[k] = c
|
|
364
826
|
kept = sorted(best_of.values(), key=lambda c: (-c['n'], -(c['bytes'] or 0)))
|
|
827
|
+
# 被同簇兄弟淘汰的 media 仍要能指到胜出者——封面底图常常是簇里 n 最小的那张
|
|
828
|
+
alias = {}
|
|
829
|
+
for c in cands:
|
|
830
|
+
w = best_of.get(c['cluster'] or c['media'])
|
|
831
|
+
if w and w['media'] != c['media']:
|
|
832
|
+
alias[c['media']] = w['media']
|
|
833
|
+
cover_media = alias.get(cover_media, cover_media)
|
|
834
|
+
bg_needed = {alias.get(m, m) for m in (bg_needed or ())}
|
|
365
835
|
|
|
366
836
|
assets, rejected, todos = [], [], []
|
|
837
|
+
over_cap_bgs = []
|
|
367
838
|
logo_pool = []
|
|
368
839
|
bg_under = bg_under or {}
|
|
369
840
|
bg_i = 0
|
|
370
|
-
canvas_w = d['canvas']['px']
|
|
841
|
+
canvas_w, canvas_h = d['canvas']['px']
|
|
371
842
|
for c in kept:
|
|
372
|
-
|
|
373
|
-
|
|
843
|
+
effective_am = c.get('effective_alpha_mean')
|
|
844
|
+
if (c['fullscreen'] and (c['probe'].get('near_blank')
|
|
845
|
+
or (effective_am is not None and effective_am < 13))):
|
|
846
|
+
rejected.append((c, '近全透明(alpha 均值 %.0f/255),PPT 里看不见'
|
|
847
|
+
% (effective_am if effective_am is not None
|
|
848
|
+
else c['probe']['alpha_mean'])))
|
|
849
|
+
continue
|
|
850
|
+
# 铺满 ≠ 能当背景。背景的定义性属性是**遮盖**:它得挡住底下的东西。一张大半透明
|
|
851
|
+
# 的图铺满整页也遮不住任何像素,它在 PPT 里是叠在幻灯片底色上的一层装饰(顶部
|
|
852
|
+
# 光晕之类),底色才是真背景。实测某模板一张 alpha 均值 30/255、72% 完全透明的
|
|
853
|
+
# 顶部光晕被当成满屏背景收进包,消费端每页铺它,顶部就多出一条原稿没有的浓色带。
|
|
854
|
+
am = effective_am if effective_am is not None else c['probe'].get('alpha_mean')
|
|
855
|
+
if c['fullscreen'] and am is not None and am < OPAQUE_ENOUGH:
|
|
856
|
+
rejected.append((c, 'alpha 均值只有 %.0f/255,遮不住底下的东西——'
|
|
857
|
+
'它是叠在底色上的装饰层,不是背景' % am))
|
|
374
858
|
continue
|
|
375
859
|
if c['fullscreen']:
|
|
376
860
|
if c['media'] == cover_media:
|
|
377
861
|
assets.append({'id': 'bg-cover', 'kind': 'background', 'role': 'cover',
|
|
378
|
-
'src': c,
|
|
379
|
-
|
|
862
|
+
'src': c,
|
|
863
|
+
# 只有真出了压缩版才能带原图;否则 path/full 指向同一
|
|
864
|
+
# 文件,package.py 必 FAIL(封面不需要转码时就会踩到)
|
|
865
|
+
'use_full': c['has_compressed']})
|
|
866
|
+
elif c['media'] in bg_needed and bg_i < BG_CONTENT_CAP:
|
|
380
867
|
bg_i += 1
|
|
381
868
|
assets.append({'id': 'bg-content-%d' % bg_i, 'kind': 'background',
|
|
382
869
|
'role': 'content', 'src': c, 'use_full': False})
|
|
870
|
+
elif c['media'] in bg_needed:
|
|
871
|
+
over_cap_bgs.append(c)
|
|
872
|
+
rejected.append((c, '有页型以它为主底,但内容页背景已收满 %d 张' % BG_CONTENT_CAP))
|
|
383
873
|
else:
|
|
384
874
|
rejected.append((c, '满屏图但没有页面以它为主底(只在版式层备用)'))
|
|
385
|
-
elif c['w_pct'] <
|
|
875
|
+
elif c['w_pct'] < SMALL_IMG_W_PCT and c['n'] >= REPEAT_MIN:
|
|
876
|
+
# 品牌标识的共性是「小、重复出现、贴角」。这里只按贴角程度排序给出首选,
|
|
877
|
+
# 不设及格线——「多少分算 logo」没有客观依据,判断交 L 层,分项证据随 TODO 给出。
|
|
386
878
|
b = c['box']
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
score += 1 if 1.0 <= ar <= 8.0 else 0
|
|
392
|
-
score += 1 if c['n'] >= 2 else 0
|
|
393
|
-
logo_pool.append((score, c))
|
|
879
|
+
edge_x = min(b.get('x', 0), max(canvas_w - (b.get('x', 0) + (b.get('w') or 0)), 0))
|
|
880
|
+
edge_y = min(b.get('y', 0), max(canvas_h - (b.get('y', 0) + (b.get('h') or 0)), 0))
|
|
881
|
+
corner = (edge_x / canvas_w) + (edge_y / canvas_h) # 越小越贴角
|
|
882
|
+
logo_pool.append((corner, c))
|
|
394
883
|
else:
|
|
395
884
|
rejected.append((c, '内容区图片(占宽 %.0f%%,出现 %d 次)' % (c['w_pct'], c['n'])))
|
|
396
885
|
|
|
@@ -404,141 +893,884 @@ def draft_assets(d, outdir, bg_needed=(), cover_media=None, bg_under=None):
|
|
|
404
893
|
from PIL import Image
|
|
405
894
|
im = Image.open(os.path.join(outdir, row['out'])).convert('RGB')
|
|
406
895
|
b = c['box']
|
|
407
|
-
sx, sy = im.width / float(canvas_w), im.height / float(
|
|
896
|
+
sx, sy = im.width / float(canvas_w), im.height / float(canvas_h)
|
|
408
897
|
crop = im.crop((int(b.get('x', 0) * sx), int(b.get('y', 0) * sy),
|
|
409
898
|
max(int((b.get('x', 0) + b.get('w', 1)) * sx), 1),
|
|
410
899
|
max(int((b.get('y', 0) + b.get('h', 1)) * sy), 1))).resize((16, 16))
|
|
411
900
|
raw = crop.tobytes()
|
|
412
901
|
px = [raw[i:i + 3] for i in range(0, len(raw), 3)]
|
|
413
|
-
return 'light' if sum(lum(p) for p in px) / len(px) >
|
|
902
|
+
return 'light' if sum(lum(p) for p in px) / len(px) > LUM_MID else 'dark'
|
|
414
903
|
except Exception:
|
|
415
904
|
return None
|
|
416
905
|
|
|
417
|
-
|
|
418
|
-
|
|
906
|
+
# 贴角是品牌标识的定义性特征:离两边都超过画布 1/4 的重复小图,更可能是页内装饰。
|
|
907
|
+
# 这不是「多少分算 logo」那种凑出来的分数线——它直接来自「贴角」这个判据本身。
|
|
908
|
+
LOGO_CORNER_MAX = 0.5 # edge_x/W + edge_y/H,两边各 25% 即到上限
|
|
909
|
+
logo_pool.sort(key=lambda kv: (kv[0], -kv[1]['n']))
|
|
910
|
+
if logo_pool and logo_pool[0][0] > LOGO_CORNER_MAX:
|
|
911
|
+
todos.append('没有贴角的重复小图(最接近的一张离画布边 %.0f%%),本模板可能没有 logo;'
|
|
912
|
+
'确认后要么从联系表挑一张补进 manifest,要么在 gaps 写明模板无品牌标识'
|
|
913
|
+
% (logo_pool[0][0] * 50))
|
|
914
|
+
logo_pool = []
|
|
915
|
+
for i, (corner, c) in enumerate(logo_pool):
|
|
419
916
|
b = c['box']
|
|
420
|
-
if i == 0
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
917
|
+
if i == 0:
|
|
918
|
+
# 贴角、重复只能说明“像 logo”,不能替模型判定。比如一张产品功能角标也会
|
|
919
|
+
# 同时满足这些结构特征;先作为候选保留在联系表与图片槽中,由模型定为 logo
|
|
920
|
+
# 或 content,避免把内容图直接带进风格包。
|
|
921
|
+
rejected.append((c, '贴角重复小图候选(%.0fx%.0f @ %.0f,%.0f,出现 %d 次,'
|
|
922
|
+
'离画布边 %.0f%%),结合样张判断 logo 或 content'
|
|
923
|
+
% (b.get('w', 0), b.get('h', 0), b.get('x', 0), b.get('y', 0),
|
|
924
|
+
c['n'], corner * 50)))
|
|
925
|
+
else:
|
|
926
|
+
rejected.append((c, '重复小图(%.0fx%.0f @ %.0f,%.0f),贴角程度 %.0f%% 不如首选'
|
|
927
|
+
% (b.get('w', 0), b.get('h', 0), b.get('x', 0), b.get('y', 0),
|
|
928
|
+
corner * 50)))
|
|
929
|
+
|
|
930
|
+
# 体量预算:包内资产总量超 20MB 直接 FAIL(V2-6)。`use_full` 的原图是唯一可能
|
|
931
|
+
# 单张爆预算的东西(未压缩的封面级大图可以单张达到数十 MB),所以在草案期就先丢 full,
|
|
932
|
+
# 不要留给 L 层去撞门禁再回修。
|
|
933
|
+
PACK_BUDGET = 20 * 1024 * 1024
|
|
934
|
+
est = sum(min(a['src'].get('bytes') or 0, ASSET_WARN_SINGLE) for a in assets)
|
|
935
|
+
for a in sorted([x for x in assets if x['use_full']],
|
|
936
|
+
key=lambda x: -(x['src'].get('bytes') or 0)):
|
|
937
|
+
orig = a['src'].get('bytes') or 0
|
|
938
|
+
if est + orig > PACK_BUDGET * 0.9:
|
|
939
|
+
a['use_full'] = False
|
|
940
|
+
todos.append('`%s` 的原图 %.1fMB 会把包撑过 20MB 上限,草案已只保留压缩版;'
|
|
941
|
+
'确实需要原图就改走 url 承载' % (a['id'], orig / 1024.0 / 1024))
|
|
426
942
|
else:
|
|
427
|
-
|
|
428
|
-
% (b.get('w', 0), b.get('h', 0), b.get('x', 0), b.get('y', 0))))
|
|
943
|
+
est += orig
|
|
429
944
|
|
|
945
|
+
if over_cap_bgs:
|
|
946
|
+
todos.append('模板有 %d 张内容页背景超出 %d 张上限(%s);用到它们的页型在 layouts.md 里'
|
|
947
|
+
'不会有 background,需要就手工补进 manifest 并删掉不重要的那几张'
|
|
948
|
+
% (len(over_cap_bgs), BG_CONTENT_CAP,
|
|
949
|
+
'、'.join(c['file'] for c in over_cap_bgs[:5])))
|
|
430
950
|
if not any(a['role'] == 'cover' for a in assets):
|
|
431
951
|
todos.append('没定出封面底图——从联系表挑一张补进 manifest(role: cover),或在 gaps 写明模板无封面主视觉')
|
|
432
952
|
copy_logo_candidates(outdir, logo_pool)
|
|
433
|
-
return assets, rejected, todos
|
|
953
|
+
return assets, rejected, todos, alias, {c['media']: c for c in kept}
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
def cover_background_media(archetypes):
|
|
957
|
+
"""只从模板明确命名的 cover 页型读取封面背景。"""
|
|
958
|
+
return next((archetype['bg_raw'] for archetype in archetypes
|
|
959
|
+
if archetype['name'] == 'cover'), None)
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
def background_decor(background, canvas):
|
|
963
|
+
"""把可直接重放的 PPT 背景声明落到 layouts.md 的最底层装饰。
|
|
964
|
+
|
|
965
|
+
图片背景继续走 `background:` 资产引用;纯色和线性渐变没有可复制的素材文件,
|
|
966
|
+
但同样是页型视觉的一部分,必须随页型输出。path 渐变不能由 CSS 线性渐变准确表达,
|
|
967
|
+
保留给图片/渲染链路而不伪造。
|
|
968
|
+
"""
|
|
969
|
+
if not isinstance(background, dict):
|
|
970
|
+
return None
|
|
971
|
+
if background.get('type') not in ('solid', 'gradient'):
|
|
972
|
+
return None
|
|
973
|
+
if background.get('path'):
|
|
974
|
+
return None
|
|
975
|
+
css = _load_query()._recipe_css(background, None, [], [])
|
|
976
|
+
css = [re.sub(r'\s*\n\s*', ' ', value).strip() for value in css if value]
|
|
977
|
+
if not css:
|
|
978
|
+
return None
|
|
979
|
+
width, height = canvas
|
|
980
|
+
return {'box': [0, 0, width, height], 'geom': 'rect', 'css': '; '.join(css),
|
|
981
|
+
'trace': 'canvas-background'}
|
|
982
|
+
|
|
983
|
+
|
|
984
|
+
def background_identity(background):
|
|
985
|
+
"""返回可用于聚类和审计的稳定背景标识,不改变背景的原始表达。"""
|
|
986
|
+
if isinstance(background, dict):
|
|
987
|
+
return json.dumps(background, sort_keys=True, separators=(',', ':'))
|
|
988
|
+
return background
|
|
434
989
|
|
|
435
990
|
|
|
436
991
|
# ---------------------------------------------------------------- 版式聚类
|
|
437
992
|
DECOR_MIN = 40.0
|
|
438
993
|
|
|
994
|
+
# 版式名 → role(模板自己按页型命名时直接用它,别再猜)。
|
|
995
|
+
# 英文词按整词匹配:裸子串会让短词吃掉长词——`end` 一度把 `agenda`、`Appendix`、
|
|
996
|
+
# `Trends Section` 全判成 closing,表里 `agenda -> section` 那条永远轮不到。
|
|
997
|
+
ROLE_BY_WORD = [('封面', 'cover'), ('cover', 'cover'), ('首页', 'cover'),
|
|
998
|
+
('title slide', 'cover'), ('标题幻灯片', 'cover'),
|
|
999
|
+
('封底', 'closing'), ('尾页', 'closing'), ('结束', 'closing'),
|
|
1000
|
+
('致谢', 'closing'), ('谢谢', 'closing'), ('end', 'closing'),
|
|
1001
|
+
('thank you', 'closing'), ('closing', 'closing'),
|
|
1002
|
+
('章节', 'section'), ('目录', 'section'), ('过渡', 'section'),
|
|
1003
|
+
('section', 'section'), ('agenda', 'section'),
|
|
1004
|
+
('section header', 'section'), ('节标题', 'section'),
|
|
1005
|
+
('金句', 'quote'), ('问句', 'quote'), ('引言', 'quote'), ('quote', 'quote'),
|
|
1006
|
+
('空白', 'blank'), ('blank', 'blank')]
|
|
1007
|
+
PH_TO_TYPE = {'title': 'title', 'ctrTitle': 'title', 'subTitle': 'subtitle',
|
|
1008
|
+
'body': 'body', 'pic': 'pic', 'clipArt': 'pic', 'tbl': 'table',
|
|
1009
|
+
'chart': 'chart', 'media': 'media', 'dgm': 'pic',
|
|
1010
|
+
'sldNum': 'slide-number', 'ftr': 'footer', 'dt': 'footer'}
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
def role_of_name(name):
|
|
1014
|
+
"""版式名 → role。认不出返回 None,由调用方降置信度并留 TODO——不要静默当 content。
|
|
1015
|
+
|
|
1016
|
+
词表只覆盖中英文;换一种语言命名的模板会整份认不出。那时全落 content 且机检照过,
|
|
1017
|
+
消费端拿到的是「每一页都是内容页」,封面/章节/结束页的语义整个丢掉且无处可查。
|
|
1018
|
+
"""
|
|
1019
|
+
low = (name or '').lower()
|
|
1020
|
+
for word, role in ROLE_BY_WORD:
|
|
1021
|
+
if word.isascii():
|
|
1022
|
+
if re.search(r'(?<![a-z])%s(?![a-z])' % re.escape(word), low):
|
|
1023
|
+
return role
|
|
1024
|
+
elif word in low:
|
|
1025
|
+
return role
|
|
1026
|
+
return None
|
|
1027
|
+
|
|
1028
|
+
|
|
1029
|
+
def clean_layout_name(name):
|
|
1030
|
+
"""`1_内容-左右排版(无副标题)` → `内容-左右排版(无副标题)`。"""
|
|
1031
|
+
return re.sub(r'^\d+[_\-\s]*', '', (name or '').strip()) or '未命名版式'
|
|
1032
|
+
|
|
1033
|
+
|
|
1034
|
+
def is_bleed(s):
|
|
1035
|
+
return (s.get('kind') == 'pic' and (s.get('w_pct') or 0) >= 95
|
|
1036
|
+
and (s.get('h_pct') or 0) >= 95)
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
def top_bleed_media(shapes):
|
|
1040
|
+
"""一串形状里最上层的满屏图。
|
|
1041
|
+
|
|
1042
|
+
OOXML 的 spTree 是绘制序,靠后的画在上面。一个版式常叠两张满屏图——通用底纹在
|
|
1043
|
+
下、这一页的主视觉在上——所以看得见的是最后那张。取第一张会拿到底纹,实测让
|
|
1044
|
+
章节页的深蓝主视觉被换成了另一张鲜蓝底纹,成品与原稿完全不是一个颜色。
|
|
1045
|
+
"""
|
|
1046
|
+
out = None
|
|
1047
|
+
for s in shapes:
|
|
1048
|
+
if is_bleed(s) and s.get('media'):
|
|
1049
|
+
out = s['media']
|
|
1050
|
+
return out
|
|
1051
|
+
|
|
1052
|
+
|
|
1053
|
+
def slot_overlaps(slots):
|
|
1054
|
+
"""同一页型里坐标互相重叠的槽对。只报事实,不改坐标——坐标是从模板量的。"""
|
|
1055
|
+
out = []
|
|
1056
|
+
for i in range(len(slots)):
|
|
1057
|
+
for j in range(i + 1, len(slots)):
|
|
1058
|
+
a, b = slots[i].get('box'), slots[j].get('box')
|
|
1059
|
+
if not (a and b):
|
|
1060
|
+
continue
|
|
1061
|
+
ox = min(a[0] + a[2], b[0] + b[2]) - max(a[0], b[0])
|
|
1062
|
+
oy = min(a[1] + a[3], b[1] + b[3]) - max(a[1], b[1])
|
|
1063
|
+
if ox > 0 and oy > 0:
|
|
1064
|
+
out.append('%s×%s 叠 %dx%d' % (slots[i].get('role'), slots[j].get('role'),
|
|
1065
|
+
round(ox), round(oy)))
|
|
1066
|
+
return out
|
|
1067
|
+
|
|
1068
|
+
|
|
1069
|
+
def css_number(value, digits=3):
|
|
1070
|
+
"""CSS 数值稳定格式:整数不带小数,其余去掉无意义尾零。"""
|
|
1071
|
+
number = round(float(value), digits)
|
|
1072
|
+
if number == int(number):
|
|
1073
|
+
return str(int(number))
|
|
1074
|
+
return ('%.*f' % (digits, number)).rstrip('0').rstrip('.')
|
|
1075
|
+
|
|
1076
|
+
|
|
1077
|
+
def slot_style(s):
|
|
1078
|
+
"""占位符自带的排版样式,统一转成可直接写进 HTML style 的 CSS 声明串。
|
|
1079
|
+
|
|
1080
|
+
样式可能在三层:lstStyle.lvl1pPr(版式占位符常用)、段落 defRPr(Mac Office
|
|
1081
|
+
导出把大量属性写在这一层)、段落 pPr(对齐)。逐层兜底,缺一层就往下取。
|
|
1082
|
+
|
|
1083
|
+
`box` 是布局几何,继续由 slot 独立承载;其余渲染属性不再泄漏成 size / color /
|
|
1084
|
+
align / insets_px 等 PPTX 中间字段。下划线开头的键仅供 draft 内部统计,emit_layouts
|
|
1085
|
+
不会写进消费者产物。
|
|
1086
|
+
"""
|
|
1087
|
+
txt = s.get('text') or {}
|
|
1088
|
+
inherited = dict((txt.get('lstStyle') or {}).get('lvl1pPr') or {})
|
|
1089
|
+
ls = {}
|
|
1090
|
+
# 四层逐级兜底,按 OOXML 的就近原则:run rPr → 段落 defRPr → 段落 pPr → lstStyle。
|
|
1091
|
+
# 只枚举前几层会整份漏掉——有的导出器把字号全写在 run rPr 上,lstStyle 一个都没有。
|
|
1092
|
+
for para in (txt.get('paragraphs') or []):
|
|
1093
|
+
srcs = [r for r in (para.get('runs') or [])]
|
|
1094
|
+
srcs.append(para.get('defRPr') or {})
|
|
1095
|
+
srcs.append({k: v for k, v in para.items() if k not in ('runs', 'defRPr')})
|
|
1096
|
+
for src in srcs:
|
|
1097
|
+
for k, v in (src or {}).items():
|
|
1098
|
+
if v is not None:
|
|
1099
|
+
ls.setdefault(k, v)
|
|
1100
|
+
for k, v in inherited.items():
|
|
1101
|
+
if v is not None:
|
|
1102
|
+
ls.setdefault(k, v)
|
|
1103
|
+
if not ls.get('sz_px'):
|
|
1104
|
+
# 仍无声明:退到整形状里出现过的最大字号(generic walk),仍是文件里的值
|
|
1105
|
+
anysz = shape_sz(s)
|
|
1106
|
+
if anysz:
|
|
1107
|
+
ls['sz_px'] = anysz
|
|
1108
|
+
body = txt.get('bodyPr') or {}
|
|
1109
|
+
css = []
|
|
1110
|
+
out = {}
|
|
1111
|
+
insets = body.get('insets_px') or {}
|
|
1112
|
+
if insets:
|
|
1113
|
+
css.append('box-sizing: border-box')
|
|
1114
|
+
css.append('padding: %spx %spx %spx %spx' % (
|
|
1115
|
+
css_number(insets.get('tIns', 0) or 0),
|
|
1116
|
+
css_number(insets.get('rIns', 0) or 0),
|
|
1117
|
+
css_number(insets.get('bIns', 0) or 0),
|
|
1118
|
+
css_number(insets.get('lIns', 0) or 0),
|
|
1119
|
+
))
|
|
1120
|
+
if ls.get('sz_px'):
|
|
1121
|
+
# normAutofit 的 fontScale 是模板让大字装进小框的手段——不乘它,消费端拿到的是
|
|
1122
|
+
# 未缩放字号,字比框高,渐变裁切会把溢出的底部切成透明。缺省 1.0(无 autofit / 无缩放)。
|
|
1123
|
+
scale = body.get('font_scale')
|
|
1124
|
+
raw = ls['sz_px'] * scale if scale else ls['sz_px']
|
|
1125
|
+
size = round(raw)
|
|
1126
|
+
css.append('font-size: %dpx' % size)
|
|
1127
|
+
out['_font_size'] = size
|
|
1128
|
+
typeface = ls.get('ea') or ls.get('latin') or ls.get('cs')
|
|
1129
|
+
if typeface:
|
|
1130
|
+
css.append('font-family: %s' % font_css([typeface]))
|
|
1131
|
+
weight = ls.get('weight') or (700 if ls.get('bold') else None)
|
|
1132
|
+
if weight:
|
|
1133
|
+
css.append('font-weight: %s' % weight)
|
|
1134
|
+
out['_font_weight'] = weight
|
|
1135
|
+
if ls.get('italic'):
|
|
1136
|
+
css.append('font-style: italic')
|
|
1137
|
+
decorations = []
|
|
1138
|
+
if ls.get('underline'):
|
|
1139
|
+
decorations.append('underline')
|
|
1140
|
+
if ls.get('strike'):
|
|
1141
|
+
decorations.append('line-through')
|
|
1142
|
+
if decorations:
|
|
1143
|
+
css.append('text-decoration: %s' % ' '.join(decorations))
|
|
1144
|
+
if ls.get('spc_px') is not None:
|
|
1145
|
+
css.append('letter-spacing: %spx' % css_number(ls['spc_px']))
|
|
1146
|
+
col = (ls.get('color') or {}).get('resolved')
|
|
1147
|
+
if col:
|
|
1148
|
+
css.append('color: %s' % col)
|
|
1149
|
+
out['_color'] = col
|
|
1150
|
+
else:
|
|
1151
|
+
# 占位符的字色也可以是 gradFill(章节页的大号序号常这么做)。解析层已经把
|
|
1152
|
+
# stops 和角度记全了,这里只取单色就会整条丢掉,消费端只能自己编一个平色。
|
|
1153
|
+
# 与 decor 同一约定:css 是可直接写进 style 的声明串。
|
|
1154
|
+
f = ls.get('fill') or {}
|
|
1155
|
+
if f.get('type') == 'gradient':
|
|
1156
|
+
g = _load_query()._css_gradient(f)
|
|
1157
|
+
if g:
|
|
1158
|
+
css += ['background-image: %s' % g, '-webkit-background-clip: text',
|
|
1159
|
+
'background-clip: text', 'color: transparent']
|
|
1160
|
+
align = ls.get('algn')
|
|
1161
|
+
if align:
|
|
1162
|
+
css.append('text-align: %s' % {
|
|
1163
|
+
'l': 'left', 'ctr': 'center', 'r': 'right', 'just': 'justify',
|
|
1164
|
+
}.get(align, align))
|
|
1165
|
+
line_spacing = ls.get('lnSpc') or {}
|
|
1166
|
+
# normAutofit 的 lnSpcReduction 与 fontScale 同时把行距压缩,一起缩才装得进原框。
|
|
1167
|
+
reduction = body.get('ln_spc_reduction') or 0
|
|
1168
|
+
if line_spacing.get('mult'):
|
|
1169
|
+
mult = line_spacing['mult'] * 1.2 * (1 - reduction)
|
|
1170
|
+
css.append('line-height: %s' % css_number(mult))
|
|
1171
|
+
elif line_spacing.get('px'):
|
|
1172
|
+
css.append('line-height: %spx' % css_number(line_spacing['px'] * (1 - reduction)))
|
|
1173
|
+
anchor = body.get('anchor')
|
|
1174
|
+
if anchor in ('ctr', 'b'):
|
|
1175
|
+
css += ['display: flex', 'flex-direction: column',
|
|
1176
|
+
'justify-content: %s' % {'ctr': 'center', 'b': 'flex-end'}[anchor]]
|
|
1177
|
+
if body.get('rot'):
|
|
1178
|
+
try:
|
|
1179
|
+
degrees = float(body['rot']) / 60000.0
|
|
1180
|
+
css.append('rotate: %sdeg' % css_number(degrees))
|
|
1181
|
+
except (TypeError, ValueError):
|
|
1182
|
+
pass
|
|
1183
|
+
if css:
|
|
1184
|
+
out['css'] = '; '.join(css)
|
|
1185
|
+
return out
|
|
1186
|
+
|
|
1187
|
+
|
|
1188
|
+
def instance_override(shapes, slide_part, slots, bgm, cW, cH, composites=None):
|
|
1189
|
+
"""实例页覆盖版式:版式是骨架,实例页才是设计师最终摆定的样子。
|
|
1190
|
+
|
|
1191
|
+
版式底图常是多个版式共用的通用底纹,实例页可能另铺主视觉大图;标题占位符的框高
|
|
1192
|
+
也常被实例页放大以容纳多行。只读版式的包会让消费端拿到错的底图和装不下字的框,
|
|
1193
|
+
只能自己缩字号。
|
|
1194
|
+
"""
|
|
1195
|
+
ins = [s for s in shapes if s.get('part') == slide_part]
|
|
1196
|
+
if not ins:
|
|
1197
|
+
return slots, bgm
|
|
1198
|
+
bgm = (composites or {}).get(slide_part) or top_bleed_media(ins) or bgm
|
|
1199
|
+
texts = []
|
|
1200
|
+
for s in ins:
|
|
1201
|
+
b = s.get('box') or {}
|
|
1202
|
+
if not (b.get('w') and b.get('h')) or not shape_text(s):
|
|
1203
|
+
continue
|
|
1204
|
+
texts.append({'sz': shape_sz(s), 'box': b, 'style': slot_style(s)})
|
|
1205
|
+
texts.sort(key=lambda x: -x['sz'])
|
|
1206
|
+
# 按字号大小依次顶替版式的文字槽(版式槽已按 y 排过,字号序更贴合语义层级)
|
|
1207
|
+
tslots = [s for s in slots if s['type'] != 'pic']
|
|
1208
|
+
for slot, ins_t in zip(sorted(tslots, key=lambda s: -(s.get('sz') or 0)), texts):
|
|
1209
|
+
b = ins_t['box']
|
|
1210
|
+
slot['box'] = [round(b.get('x', 0)), round(b.get('y', 0)),
|
|
1211
|
+
round(b.get('w', 0)), round(b.get('h', 0))]
|
|
1212
|
+
slot['sz'] = ins_t['sz']
|
|
1213
|
+
slot.update(ins_t['style'] or {})
|
|
1214
|
+
return slots, bgm
|
|
1215
|
+
|
|
1216
|
+
|
|
1217
|
+
def layouts_from_template(d, shapes, cW, cH):
|
|
1218
|
+
"""form=3:模板自己用 slideLayout 声明了页型,直接读版式层。
|
|
1219
|
+
|
|
1220
|
+
拿样张聚类只能得到「样张数」个 archetype——模板往往只放 1-2 张样张,
|
|
1221
|
+
真正的页型全在版式里。模板常见只放个位数样张却声明几十个语义版式,按样张聚类
|
|
1222
|
+
只能得到「样张数」个 archetype,消费端搭页时大半无版式可抄,只能自己编。
|
|
1223
|
+
"""
|
|
1224
|
+
by_part = defaultdict(list)
|
|
1225
|
+
for s in shapes:
|
|
1226
|
+
if (s.get('layer') == 'layout' and s.get('kind') == 'sp'
|
|
1227
|
+
and (s.get('box') or {}).get('w') and (s.get('ph') or shape_text(s))):
|
|
1228
|
+
by_part[s['part']].append(s)
|
|
1229
|
+
bg_of_layout = {}
|
|
1230
|
+
composites = d.get('background_composites') or {}
|
|
1231
|
+
for s in shapes:
|
|
1232
|
+
if s.get('layer') == 'layout' and is_bleed(s) and s.get('media'):
|
|
1233
|
+
bg_of_layout[s['part']] = s['media'] # 靠后者在上层,最后一张才是看得见的
|
|
1234
|
+
topo = d.get('theme_topology') or {}
|
|
1235
|
+
theme_of_master = {m['master']: m.get('theme_label')
|
|
1236
|
+
for m in (topo.get('per_master') or [])}
|
|
1237
|
+
master_of = (d.get('reference_graph') or {}).get('master_of_layout') or {}
|
|
1238
|
+
# 只在版式恰好被 1 张实例页使用时才拿实例覆盖:多张实例共用一个版式时,
|
|
1239
|
+
# 谁都不代表版式本身,硬挑一张会把别页的构图当成页型
|
|
1240
|
+
lay_of_slide = (d.get('reference_graph') or {}).get('layout_of_slide') or {}
|
|
1241
|
+
used_n = Counter(lay_of_slide.values())
|
|
1242
|
+
slide_of_layout = {lp: sp for sp, lp in lay_of_slide.items() if used_n[lp] == 1}
|
|
1243
|
+
sample_pages_of_layout = defaultdict(list)
|
|
1244
|
+
for slide_part, layout_part in lay_of_slide.items():
|
|
1245
|
+
sample_pages_of_layout[layout_part].append(slide_no(slide_part))
|
|
1246
|
+
sampled_theme_counts = Counter(
|
|
1247
|
+
theme_of_master.get(master_of.get(layout_part))
|
|
1248
|
+
for layout_part in lay_of_slide.values()
|
|
1249
|
+
)
|
|
1250
|
+
sampled_theme_counts.pop(None, None)
|
|
1251
|
+
default_theme = topo.get('default')
|
|
1252
|
+
if sampled_theme_counts:
|
|
1253
|
+
highest = max(sampled_theme_counts.values())
|
|
1254
|
+
leaders = sorted(
|
|
1255
|
+
theme for theme, count in sampled_theme_counts.items()
|
|
1256
|
+
if count == highest
|
|
1257
|
+
)
|
|
1258
|
+
if default_theme not in leaders:
|
|
1259
|
+
default_theme = leaders[0]
|
|
1260
|
+
topo['default'] = default_theme
|
|
1261
|
+
multi = len(topo.get('themes') or []) > 1
|
|
1262
|
+
|
|
1263
|
+
rows = []
|
|
1264
|
+
for l in d.get('layouts') or []:
|
|
1265
|
+
phs = [s for s in by_part.get(l['part'], []) if (s.get('box') or {}).get('w')]
|
|
1266
|
+
if not phs:
|
|
1267
|
+
continue
|
|
1268
|
+
theme = theme_of_master.get(master_of.get(l['part']))
|
|
1269
|
+
phs.sort(key=lambda s: ((s['box'].get('y') or 0), (s['box'].get('x') or 0)))
|
|
1270
|
+
slots, seen_kind = [], set()
|
|
1271
|
+
for s in phs:
|
|
1272
|
+
t = PH_TO_TYPE.get((s.get('ph') or {}).get('type'), 'body')
|
|
1273
|
+
if t in ('slide-number', 'footer') and not shape_text(s):
|
|
1274
|
+
continue # 空 chrome 占位符不是实际元素
|
|
1275
|
+
b = s['box']
|
|
1276
|
+
role = t if t in ('title', 'subtitle', 'footer', 'slide-number') else 'body'
|
|
1277
|
+
if t == 'title' and 'title' in seen_kind:
|
|
1278
|
+
role, t = 'subtitle', 'subtitle'
|
|
1279
|
+
seen_kind.add(t)
|
|
1280
|
+
row = {'role': role, 'type': t, 'sz': shape_sz(s),
|
|
1281
|
+
'box': [round(b.get('x', 0)), round(b.get('y', 0)),
|
|
1282
|
+
round(b.get('w', 0)), round(b.get('h', 0))],
|
|
1283
|
+
'txt': shape_text(s) or (s.get('name') or '')[:24]}
|
|
1284
|
+
row.update(slot_style(s))
|
|
1285
|
+
if t == 'body':
|
|
1286
|
+
ph = s.get('ph') or {}
|
|
1287
|
+
row.update({
|
|
1288
|
+
'_needs_role': True,
|
|
1289
|
+
'_source_layer': 'layout',
|
|
1290
|
+
'_placeholder': '%s/%s' % (
|
|
1291
|
+
ph.get('type') or '-', ph.get('idx') or '-'),
|
|
1292
|
+
})
|
|
1293
|
+
slots.append(row)
|
|
1294
|
+
# 非满屏的图片元素(logo / 联名标 / 装饰)——它们逐版式换位置换尺寸,
|
|
1295
|
+
# 必须按版式落进 slots,压成一条全局「固定位」规则就会撞标题。
|
|
1296
|
+
bgm = composites.get(l['part']) or bg_of_layout.get(l['part'])
|
|
1297
|
+
for s in shapes:
|
|
1298
|
+
if s['part'] != l['part'] or s.get('kind') != 'pic' or not s.get('media'):
|
|
1299
|
+
continue
|
|
1300
|
+
if s['media'] == bgm or (s.get('w_pct', 0) >= 95 and s.get('h_pct', 0) >= 95):
|
|
1301
|
+
continue
|
|
1302
|
+
b = s.get('box') or {}
|
|
1303
|
+
if not b.get('w'):
|
|
1304
|
+
continue
|
|
1305
|
+
slots.append({'role': 'logo', 'type': 'pic', 'sz': 0, 'txt': '',
|
|
1306
|
+
'media': s['media'],
|
|
1307
|
+
'box': [round(b.get('x', 0)), round(b.get('y', 0)),
|
|
1308
|
+
round(b.get('w', 0)), round(b.get('h', 0))]})
|
|
1309
|
+
if not slots:
|
|
1310
|
+
continue
|
|
1311
|
+
inst = slide_of_layout.get(l['part'])
|
|
1312
|
+
if inst:
|
|
1313
|
+
slots, bgm = instance_override(
|
|
1314
|
+
shapes, inst, slots, bgm, cW, cH, composites)
|
|
1315
|
+
taken = {tuple(s['box']) for s in slots}
|
|
1316
|
+
decor = []
|
|
1317
|
+
inherited_background = l.get('background')
|
|
1318
|
+
direct_background = next(
|
|
1319
|
+
(slide.get('background') for slide in d.get('slides') or []
|
|
1320
|
+
if slide.get('part') == inst and slide.get('background')),
|
|
1321
|
+
None)
|
|
1322
|
+
background = direct_background or inherited_background
|
|
1323
|
+
background_layer = background_decor(background, (cW, cH))
|
|
1324
|
+
if background_layer:
|
|
1325
|
+
decor.append(background_layer)
|
|
1326
|
+
decor += collect_decor(shapes, inst or l['part'], taken, (cW, cH))
|
|
1327
|
+
named_role = role_of_name(l.get('name'))
|
|
1328
|
+
rows.append({'zh': clean_layout_name(l.get('name')),
|
|
1329
|
+
'role': named_role or 'content', 'role_guessed': named_role is None,
|
|
1330
|
+
'slots': slots, 'decor': decor, 'bg_raw': bgm,
|
|
1331
|
+
'theme': theme, 'part': l['part'],
|
|
1332
|
+
'used': l.get('used_by_slides') or 0})
|
|
1333
|
+
|
|
1334
|
+
# 同名版式在 dark/light 两套 master 下各有一份——按名字归一,优先默认主题那份
|
|
1335
|
+
best = {}
|
|
1336
|
+
for r in rows:
|
|
1337
|
+
k = r['zh']
|
|
1338
|
+
cur = best.get(k)
|
|
1339
|
+
if cur is None or (r['theme'] == default_theme and cur['theme'] != default_theme) \
|
|
1340
|
+
or (r['used'] > cur['used']):
|
|
1341
|
+
best[k] = r
|
|
1342
|
+
picked = sorted(best.values(), key=lambda r: (
|
|
1343
|
+
['cover', 'section', 'quote', 'content', 'closing', 'blank'].index(r['role'])
|
|
1344
|
+
if r['role'] in ('cover', 'section', 'quote', 'content', 'closing', 'blank') else 9,
|
|
1345
|
+
-r['used'], r['part']))
|
|
1346
|
+
|
|
1347
|
+
used_key = Counter()
|
|
1348
|
+
arch = []
|
|
1349
|
+
for r in picked:
|
|
1350
|
+
used_key[r['role']] += 1
|
|
1351
|
+
n = used_key[r['role']]
|
|
1352
|
+
key = r['role'] if n == 1 else '%s-%d' % (r['role'], n)
|
|
1353
|
+
m_no = re.search(r'slideLayout(\d+)\.xml$', r['part'])
|
|
1354
|
+
arch.append({'name': key, 'zh': r['zh'], 'role': r['role'], 'bg': None,
|
|
1355
|
+
'role_guessed': r.get('role_guessed'),
|
|
1356
|
+
'bg_raw': r['bg_raw'], 'slots': r['slots'],
|
|
1357
|
+
'decor': r.get('decor') or [], 'pages': [],
|
|
1358
|
+
'rep': None, 'rep_layout': int(m_no.group(1)) if m_no else None,
|
|
1359
|
+
# 版式名认不出 role 时不装作有把握:置信度降到 low,让 L 层看图定
|
|
1360
|
+
'pic_n': 0, 'confidence': 'low' if r.get('role_guessed') else 'high',
|
|
1361
|
+
'theme': r['theme'] if multi else None,
|
|
1362
|
+
'_layout_part': r['part'],
|
|
1363
|
+
'_sample_pages': sorted(sample_pages_of_layout.get(r['part']) or []),
|
|
1364
|
+
'source': 'layout:' + r['part'].split('/')[-1]})
|
|
1365
|
+
return arch
|
|
1366
|
+
|
|
1367
|
+
|
|
1368
|
+
_QUERY = []
|
|
1369
|
+
|
|
1370
|
+
|
|
1371
|
+
def _load_query():
|
|
1372
|
+
"""复用 query.py 的 OOXML→CSS 渲染,不再写第二份。"""
|
|
1373
|
+
if not _QUERY:
|
|
1374
|
+
import importlib.util
|
|
1375
|
+
spec = importlib.util.spec_from_file_location('_q', os.path.join(HERE, 'query.py'))
|
|
1376
|
+
mod = importlib.util.module_from_spec(spec)
|
|
1377
|
+
spec.loader.exec_module(mod)
|
|
1378
|
+
_QUERY.append(mod)
|
|
1379
|
+
return _QUERY[0]
|
|
1380
|
+
|
|
1381
|
+
|
|
1382
|
+
def collect_decor(shapes, part, taken_boxes, canvas, limit=10):
|
|
1383
|
+
"""页面上撑起版式骨架、但不含文字的形状(圆形图标托、卡片、分隔线)。
|
|
1384
|
+
|
|
1385
|
+
只给文字框的坐标,消费端看到的是「一段说明悬在半空、上方一片空白」,只能自己编
|
|
1386
|
+
容器,编出来的形状与模板无关。这些形状必须进包。
|
|
1387
|
+
"""
|
|
1388
|
+
q = _load_query()
|
|
1389
|
+
cW, cH = canvas
|
|
1390
|
+
out = []
|
|
1391
|
+
for s in shapes:
|
|
1392
|
+
if s.get('part') != part or s.get('kind') != 'sp':
|
|
1393
|
+
continue
|
|
1394
|
+
if any(r.get('text', '').strip()
|
|
1395
|
+
for para in ((s.get('text') or {}).get('paragraphs') or [])
|
|
1396
|
+
for r in (para.get('runs') or [])):
|
|
1397
|
+
continue # 有文字的已经作为 slot 出过
|
|
1398
|
+
b = s.get('box') or {}
|
|
1399
|
+
w, h = b.get('w') or 0, b.get('h') or 0
|
|
1400
|
+
if not (w or h):
|
|
1401
|
+
continue # 零尺寸形状渲染不出任何东西
|
|
1402
|
+
if canvas_coverage(b, cW, cH) >= FULLSCREEN_COVERAGE:
|
|
1403
|
+
continue # 满屏底,属 background
|
|
1404
|
+
box = [round(b.get('x', 0)), round(b.get('y', 0)), round(w), round(h)]
|
|
1405
|
+
if tuple(box) in taken_boxes:
|
|
1406
|
+
continue
|
|
1407
|
+
css = q._recipe_css(s.get('fill'), s.get('line'),
|
|
1408
|
+
[s.get('radius_px')] if s.get('radius_px') else [], s.get('effects'))
|
|
1409
|
+
# 声明要落成单行:含换行的声明会被下游的行式解析器从换行处截断,
|
|
1410
|
+
# 且只记 PARSE-WARN 不 FAIL,整包照常出厂——带着半条渲染不出来的 CSS
|
|
1411
|
+
css = [re.sub(r'\s*\n\s*', ' ', c.split('\x00')[0]).strip() for c in css if c]
|
|
1412
|
+
if not css:
|
|
1413
|
+
continue # 无填充无描边无阴影 = 看不见,不占篇幅
|
|
1414
|
+
out.append({'box': box, 'geom': (s.get('geom') or {}).get('prst') or 'rect',
|
|
1415
|
+
'css': '; '.join(css), 'area': max(w * h, w, h)})
|
|
1416
|
+
# 按面积降序取前 limit 条:撑起版式的结构性形状总在最前,零星噪点自然落在截断线外,
|
|
1417
|
+
# 不需要再设一个「多小算噪点」的尺寸门槛(那种门槛会误杀 1px 分隔线)。
|
|
1418
|
+
out.sort(key=lambda d: -d['area'])
|
|
1419
|
+
note_truncation('装饰形状', limit, len(out), '按面积降序保留,剩下的多是零星小件',
|
|
1420
|
+
part.split('/')[-1])
|
|
1421
|
+
return out[:limit] # 同款不同位置都要留,位置本身是版式信息
|
|
1422
|
+
|
|
1423
|
+
|
|
1424
|
+
def placeholder_key(shape):
|
|
1425
|
+
ph = shape.get('ph') or {}
|
|
1426
|
+
if not ph:
|
|
1427
|
+
return None
|
|
1428
|
+
return (ph.get('type') or 'body', str(ph.get('idx') or ''))
|
|
1429
|
+
|
|
439
1430
|
|
|
440
|
-
def
|
|
441
|
-
|
|
1431
|
+
def merge_dict(base, override):
|
|
1432
|
+
"""把实例页的非空声明叠到版式声明上;空实例占位符继续继承版式事实。"""
|
|
1433
|
+
out = copy.deepcopy(base or {})
|
|
1434
|
+
for key, value in (override or {}).items():
|
|
1435
|
+
if value is None or value == []:
|
|
1436
|
+
continue
|
|
1437
|
+
if isinstance(value, dict) and isinstance(out.get(key), dict):
|
|
1438
|
+
out[key] = merge_dict(out[key], value)
|
|
1439
|
+
else:
|
|
1440
|
+
out[key] = copy.deepcopy(value)
|
|
1441
|
+
return out
|
|
1442
|
+
|
|
1443
|
+
|
|
1444
|
+
def inherited_text_shapes(layout_shapes, slide_shapes):
|
|
1445
|
+
"""返回实例页可用的文字形状,并补齐其引用版式中的占位符几何与样式。"""
|
|
1446
|
+
layout_text = []
|
|
1447
|
+
for shape in layout_shapes:
|
|
1448
|
+
if shape.get('kind') != 'sp' or not (shape.get('box') or {}).get('w'):
|
|
1449
|
+
continue
|
|
1450
|
+
ph = shape.get('ph') or {}
|
|
1451
|
+
ph_type = ph.get('type')
|
|
1452
|
+
if shape_text(shape) or (ph and ph_type not in ('ftr', 'dt', 'sldNum')):
|
|
1453
|
+
layout_text.append(shape)
|
|
1454
|
+
by_placeholder = {placeholder_key(s): s for s in layout_text if placeholder_key(s)}
|
|
1455
|
+
used = set()
|
|
1456
|
+
out = []
|
|
1457
|
+
for shape in slide_shapes:
|
|
1458
|
+
if shape.get('kind') != 'sp':
|
|
1459
|
+
continue
|
|
1460
|
+
key = placeholder_key(shape)
|
|
1461
|
+
base = by_placeholder.get(key)
|
|
1462
|
+
if base:
|
|
1463
|
+
merged = merge_dict(base, shape)
|
|
1464
|
+
merged['text'] = merge_dict(base.get('text'), shape.get('text'))
|
|
1465
|
+
if not shape_text(shape):
|
|
1466
|
+
merged['text']['paragraphs'] = copy.deepcopy(
|
|
1467
|
+
(base.get('text') or {}).get('paragraphs') or [])
|
|
1468
|
+
used.add(key)
|
|
1469
|
+
out.append((merged, 'slide+layout'))
|
|
1470
|
+
elif (shape.get('box') or {}).get('w') and shape_text(shape):
|
|
1471
|
+
out.append((shape, 'slide'))
|
|
1472
|
+
for shape in layout_text:
|
|
1473
|
+
key = placeholder_key(shape)
|
|
1474
|
+
if key not in used:
|
|
1475
|
+
out.append((shape, 'layout'))
|
|
1476
|
+
return out
|
|
1477
|
+
|
|
1478
|
+
|
|
1479
|
+
def slide_image_marks(data, included_fullscreen=()):
|
|
1480
|
+
"""从图片普查补齐形状图片填充;它们没有独立 pic 节点,但仍有媒体与坐标。"""
|
|
1481
|
+
allowed_fullscreen = set(included_fullscreen)
|
|
1482
|
+
out = defaultdict(list)
|
|
1483
|
+
for image in data.get('images') or []:
|
|
1484
|
+
media = image.get('media')
|
|
1485
|
+
if not media or (image.get('fullscreen') and media not in allowed_fullscreen):
|
|
1486
|
+
continue
|
|
1487
|
+
for cluster in image.get('boxes') or []:
|
|
1488
|
+
box = cluster.get('box')
|
|
1489
|
+
if not box or not box.get('w'):
|
|
1490
|
+
continue
|
|
1491
|
+
for part in cluster.get('parts') or []:
|
|
1492
|
+
if '/slides/' not in part and '/slideLayouts/' not in part:
|
|
1493
|
+
continue
|
|
1494
|
+
out[part].append({'media': media, 'box': box})
|
|
1495
|
+
return out
|
|
1496
|
+
|
|
1497
|
+
|
|
1498
|
+
def add_template_image_marks(archetypes, data, included_fullscreen=()):
|
|
1499
|
+
"""把版式和实例页的图片填充补进 form=3 页型。"""
|
|
1500
|
+
marks_by_part = slide_image_marks(data, included_fullscreen)
|
|
1501
|
+
layout_of_slide = (data.get('reference_graph') or {}).get('layout_of_slide') or {}
|
|
1502
|
+
by_layout = {archetype.get('_layout_part'): archetype for archetype in archetypes}
|
|
1503
|
+
for part, marks in marks_by_part.items():
|
|
1504
|
+
layout_part = layout_of_slide.get(part, part)
|
|
1505
|
+
archetype = by_layout.get(layout_part)
|
|
1506
|
+
if not archetype:
|
|
1507
|
+
continue
|
|
1508
|
+
seen = {
|
|
1509
|
+
(slot.get('media'), tuple(slot.get('box') or ()))
|
|
1510
|
+
for slot in archetype.get('slots') or []
|
|
1511
|
+
if slot.get('media')
|
|
1512
|
+
}
|
|
1513
|
+
for mark in marks:
|
|
1514
|
+
box = mark['box']
|
|
1515
|
+
rounded = [round(box.get(key, 0)) for key in ('x', 'y', 'w', 'h')]
|
|
1516
|
+
key = (mark['media'], tuple(rounded))
|
|
1517
|
+
if key in seen:
|
|
1518
|
+
continue
|
|
1519
|
+
seen.add(key)
|
|
1520
|
+
archetype['slots'].append({
|
|
1521
|
+
'role': 'logo',
|
|
1522
|
+
'type': 'pic',
|
|
1523
|
+
'sz': 0,
|
|
1524
|
+
'txt': '',
|
|
1525
|
+
'media': mark['media'],
|
|
1526
|
+
'box': rounded,
|
|
1527
|
+
})
|
|
1528
|
+
|
|
1529
|
+
|
|
1530
|
+
def preserve_image_bearing_groups(kept, ranked):
|
|
1531
|
+
"""有图片实例的孤例保留自己的页型,避免把资产绑定到近似但错误的版式。"""
|
|
1532
|
+
return kept + [
|
|
1533
|
+
group for group in ranked
|
|
1534
|
+
if group not in kept and any(page.get('marks') for page in group[1])
|
|
1535
|
+
]
|
|
1536
|
+
|
|
1537
|
+
|
|
1538
|
+
def draft_layouts(d, outdir, effective_alpha=None):
|
|
1539
|
+
with open(os.path.join(outdir, 'ref', 'shapes.json'), encoding='utf-8') as stream:
|
|
1540
|
+
shapes = json.load(stream)['shapes']
|
|
442
1541
|
cW, cH = d['canvas']['px']
|
|
1542
|
+
if effective_alpha is None:
|
|
1543
|
+
effective_alpha = fullscreen_effective_alpha(d, outdir, shapes)
|
|
1544
|
+
overlay_media = {
|
|
1545
|
+
media for media, alpha in effective_alpha.items()
|
|
1546
|
+
if 13 <= alpha < OPAQUE_ENOUGH
|
|
1547
|
+
}
|
|
1548
|
+
if (d.get('form_hint') or {}).get('form') == 3:
|
|
1549
|
+
arch = layouts_from_template(d, shapes, cW, cH)
|
|
1550
|
+
if len(arch) >= 3:
|
|
1551
|
+
add_template_image_marks(arch, d, overlay_media)
|
|
1552
|
+
return arch, [], []
|
|
443
1553
|
by_slide = defaultdict(list)
|
|
1554
|
+
by_layout = defaultdict(list)
|
|
444
1555
|
for s in shapes:
|
|
445
1556
|
if s.get('layer') == 'slide':
|
|
446
1557
|
by_slide[s['part']].append(s)
|
|
1558
|
+
elif s.get('layer') == 'layout':
|
|
1559
|
+
by_layout[s['part']].append(s)
|
|
1560
|
+
image_marks = slide_image_marks(d, overlay_media)
|
|
447
1561
|
|
|
448
1562
|
bg_of_slide, layout_of_slide = {}, {}
|
|
1563
|
+
background_of_layout = {
|
|
1564
|
+
row['part']: row.get('background') for row in d.get('layouts') or []
|
|
1565
|
+
}
|
|
449
1566
|
for s in d.get('slides', []):
|
|
450
1567
|
bg = s.get('background')
|
|
451
|
-
bg_of_slide[s['part']] =
|
|
1568
|
+
bg_of_slide[s['part']] = background_identity(bg)
|
|
452
1569
|
layout_of_slide[s['part']] = s.get('layout')
|
|
453
1570
|
# 版式层的满屏底图(form=2 常态:底图挂在 layout 上)
|
|
454
|
-
|
|
1571
|
+
composites = d.get('background_composites') or {}
|
|
1572
|
+
bg_media_of_layout = {}
|
|
455
1573
|
for s in shapes:
|
|
456
|
-
if
|
|
457
|
-
|
|
458
|
-
bg_of_layout.setdefault(s['part'], s.get('media'))
|
|
1574
|
+
if s.get('layer') == 'layout' and is_bleed(s) and s.get('media'):
|
|
1575
|
+
bg_media_of_layout[s['part']] = s['media']
|
|
459
1576
|
|
|
460
1577
|
pages = []
|
|
461
1578
|
for part, sh in sorted(by_slide.items(), key=lambda kv: slide_no(kv[0])):
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
bg_media = s.get('media')
|
|
466
|
-
break
|
|
1579
|
+
layout_part = layout_of_slide.get(part)
|
|
1580
|
+
layout_shapes = by_layout.get(layout_part) or []
|
|
1581
|
+
bg_media = top_bleed_media(sh)
|
|
467
1582
|
if bg_media is None:
|
|
468
|
-
bg_media =
|
|
1583
|
+
bg_media = bg_media_of_layout.get(layout_part)
|
|
1584
|
+
rendered_bg = (composites.get(part)
|
|
1585
|
+
or composites.get(layout_part)
|
|
1586
|
+
or bg_media)
|
|
469
1587
|
texts = []
|
|
470
|
-
for s in sh:
|
|
471
|
-
|
|
472
|
-
continue
|
|
473
|
-
txt = shape_text(s)
|
|
474
|
-
if not txt:
|
|
475
|
-
continue
|
|
1588
|
+
for s, source_layer in inherited_text_shapes(layout_shapes, sh):
|
|
1589
|
+
txt = shape_text(s) or (s.get('name') or '')[:24]
|
|
476
1590
|
b = s.get('box') or {}
|
|
477
1591
|
if b.get('w', 0) < DECOR_MIN or b.get('h', 0) < 16:
|
|
478
1592
|
continue
|
|
479
|
-
|
|
1593
|
+
ph = s.get('ph') or {}
|
|
1594
|
+
ph_type = ph.get('type')
|
|
1595
|
+
direct_type = PH_TO_TYPE.get(ph_type, 'body')
|
|
1596
|
+
texts.append({
|
|
1597
|
+
'sz': shape_sz(s),
|
|
1598
|
+
'box': b,
|
|
1599
|
+
'txt': txt,
|
|
1600
|
+
'style': slot_style(s),
|
|
1601
|
+
'direct_type': direct_type,
|
|
1602
|
+
'needs_role': direct_type == 'body',
|
|
1603
|
+
'source_layer': source_layer,
|
|
1604
|
+
'placeholder': '%s/%s' % (ph_type or '-', ph.get('idx') or '-'),
|
|
1605
|
+
})
|
|
480
1606
|
texts.sort(key=lambda t: (-t['sz'], t['box'].get('y', 0)))
|
|
481
|
-
|
|
1607
|
+
visible_shapes = layout_shapes + sh
|
|
1608
|
+
pics = []
|
|
1609
|
+
for shape in visible_shapes:
|
|
1610
|
+
if shape.get('kind') != 'pic':
|
|
1611
|
+
continue
|
|
1612
|
+
if shape.get('w_pct', 0) < 95 or shape.get('media') in overlay_media:
|
|
1613
|
+
pics.append(shape)
|
|
1614
|
+
# 小图元素(logo / 角标 / 装饰)逐页记位置,供 archetype 落 slots
|
|
1615
|
+
marks = [{'media': s['media'], 'box': s['box']} for s in pics
|
|
1616
|
+
if s.get('media') and (s.get('box') or {}).get('w')]
|
|
1617
|
+
seen_marks = {
|
|
1618
|
+
(mark['media'], round(mark['box'].get('x', 0)), round(mark['box'].get('y', 0)))
|
|
1619
|
+
for mark in marks
|
|
1620
|
+
}
|
|
1621
|
+
for mark in image_marks.get(part) or []:
|
|
1622
|
+
key = (mark['media'], round(mark['box'].get('x', 0)),
|
|
1623
|
+
round(mark['box'].get('y', 0)))
|
|
1624
|
+
if key not in seen_marks:
|
|
1625
|
+
seen_marks.add(key)
|
|
1626
|
+
marks.append(mark)
|
|
1627
|
+
background = next((s.get('background') for s in d.get('slides') or []
|
|
1628
|
+
if s.get('part') == part and s.get('background')), None)
|
|
1629
|
+
background = background or background_of_layout.get(layout_part)
|
|
482
1630
|
pages.append({'part': part, 'no': slide_no(part), 'bg_media': bg_media,
|
|
483
|
-
'
|
|
484
|
-
'
|
|
485
|
-
|
|
486
|
-
|
|
1631
|
+
'rendered_bg': rendered_bg,
|
|
1632
|
+
'bg_color': bg_of_slide.get(part), 'background': background,
|
|
1633
|
+
'texts': texts, 'pic_n': len(pics),
|
|
1634
|
+
'marks': marks, 'shape_n': len(visible_shapes), 'layout': layout_part})
|
|
1635
|
+
|
|
1636
|
+
# 页型的**角色**(封面 / 章节页 / 内容页……)不在这里判:那是看图才能下的结论,
|
|
1637
|
+
# 交给读得到重建图的模型。脚本只做客观归并——同一张底图 + 文字块数量相近的页
|
|
1638
|
+
# 归成一组,档位按本 deck 自己的分布切,不用「字号 ≥60 就是章节页」这类固定数。
|
|
1639
|
+
ns = sorted(len(p['texts']) for p in pages) or [0]
|
|
1640
|
+
q1, q2 = ns[len(ns) // 3], ns[len(ns) * 2 // 3]
|
|
1641
|
+
|
|
1642
|
+
def density_band(p):
|
|
487
1643
|
n = len(p['texts'])
|
|
488
|
-
|
|
489
|
-
if p['no'] == 1:
|
|
490
|
-
return 'cover'
|
|
491
|
-
if n <= 3 and top >= 60:
|
|
492
|
-
return 'section'
|
|
493
|
-
if n >= 8 or p['pic_n'] >= 4:
|
|
494
|
-
return 'content-dense'
|
|
495
|
-
return 'content'
|
|
1644
|
+
return 0 if n <= q1 else (1 if n <= q2 else 2)
|
|
496
1645
|
|
|
1646
|
+
last_page_no = max((p['no'] for p in pages), default=None)
|
|
497
1647
|
groups = defaultdict(list)
|
|
498
1648
|
for p in pages:
|
|
499
|
-
|
|
1649
|
+
if p['no'] == 1:
|
|
1650
|
+
# 首页单独成组:它是 deck 唯一的入口页,版面通常和后面任何一页都不同,
|
|
1651
|
+
# 并进别的组就会被代表页顶掉、坐标全丢。这只是不合并,不代表它是封面。
|
|
1652
|
+
groups[('__first__', -1)] = [p]
|
|
1653
|
+
continue
|
|
1654
|
+
if p['no'] == last_page_no:
|
|
1655
|
+
# 末页也单独保留完整结构:它可能是封底,也可能只是最后一张内容页,脚本
|
|
1656
|
+
# 不替模型下结论。和首页一样,拆组只避免它被聚类代表页吞掉。
|
|
1657
|
+
groups[('__last__', -2)] = [p]
|
|
1658
|
+
continue
|
|
1659
|
+
background_key = background_identity(
|
|
1660
|
+
p.get('rendered_bg') or p.get('bg_media')
|
|
1661
|
+
or p.get('background') or p.get('bg_color')
|
|
1662
|
+
) or 'none'
|
|
1663
|
+
groups[(background_key, density_band(p))].append(p)
|
|
500
1664
|
|
|
501
1665
|
ranked = sorted(groups.items(), key=lambda kv: (-len(kv[1]), kv[1][0]['no']))
|
|
502
|
-
|
|
1666
|
+
# 首页所在的组一定收——deck 的第一页是模板的门面,孤例也不能被名额挤掉。
|
|
1667
|
+
# 这只保证它进包,它是不是封面由看图的人定。
|
|
1668
|
+
first = [g for g in ranked if g[0][0] == '__first__']
|
|
1669
|
+
last = [g for g in ranked if g[0][0] == '__last__']
|
|
1670
|
+
kept = first + last + [
|
|
1671
|
+
g for g in ranked
|
|
1672
|
+
if g not in first and g not in last and len(g[1]) >= 2
|
|
1673
|
+
][:max(0, 8 - len(first) - len(last))]
|
|
503
1674
|
for g in ranked: # 名额没用满就把最大的孤例页也收进来
|
|
504
1675
|
if len(kept) >= 8:
|
|
505
1676
|
break
|
|
506
1677
|
if g not in kept:
|
|
507
1678
|
kept.append(g)
|
|
1679
|
+
# 图片用途必须与它实际所在的版式绑定。若把图片孤例并到“最接近”页型,装饰会被
|
|
1680
|
+
# 绑定到错误布局;是否为内容图、logo 墙或装饰由后续模型看图判断,不按图片数量猜。
|
|
1681
|
+
kept = preserve_image_bearing_groups(kept, ranked)
|
|
508
1682
|
leftover = sorted(p['no'] for g in ranked if g not in kept for p in g[1])
|
|
509
1683
|
|
|
510
1684
|
archetypes = []
|
|
511
|
-
|
|
512
|
-
for (bg_raw, kind), ps in kept:
|
|
1685
|
+
for gi, ((_background_key, _band), ps) in enumerate(kept, 1):
|
|
513
1686
|
rep = max(ps, key=lambda p: len(p['texts']))
|
|
514
|
-
|
|
515
|
-
name =
|
|
516
|
-
# 标题按「位置 +
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
1687
|
+
bg_raw = rep.get('rendered_bg') or rep.get('bg_media')
|
|
1688
|
+
name = 'layout-%d' % gi
|
|
1689
|
+
# 标题按「位置 + 跨度」认,不按字号——big-number 类的巨号数值常比标题还大
|
|
1690
|
+
# 标题 = 该页最靠上的那批文本里最宽的一块。不按「画布前 28%」这类固定比例切:
|
|
1691
|
+
# 版心靠下的模板会整页认不出标题。以该页自身的文本框分布定「靠上」。
|
|
1692
|
+
ys = sorted(t['box'].get('y', 0) for t in rep['texts'])
|
|
1693
|
+
y_cut = ys[max(len(ys) // 4, 0)] if ys else 0
|
|
1694
|
+
band = [t for t in rep['texts'] if t['box'].get('y', 1e9) <= y_cut]
|
|
1695
|
+
title = max(band, key=lambda t: (t['box'].get('w', 0), t['sz'])) if band else (
|
|
520
1696
|
max(rep['texts'], key=lambda t: t['sz']) if rep['texts'] else None)
|
|
521
1697
|
rest = [t for t in rep['texts'] if t is not title]
|
|
522
1698
|
rest.sort(key=lambda t: (t['box'].get('y', 0), t['box'].get('x', 0)))
|
|
523
1699
|
ordered = ([title] if title else []) + rest
|
|
524
1700
|
slots = []
|
|
525
|
-
for i, t in enumerate(ordered
|
|
1701
|
+
for i, t in enumerate(ordered):
|
|
526
1702
|
b = t['box']
|
|
527
|
-
if t
|
|
1703
|
+
if t.get('needs_role'):
|
|
1704
|
+
role = typ = 'body'
|
|
1705
|
+
elif t.get('direct_type') in ('title', 'subtitle', 'footer', 'slide-number'):
|
|
1706
|
+
role = typ = t['direct_type']
|
|
1707
|
+
elif t is title:
|
|
528
1708
|
role = typ = 'title'
|
|
529
|
-
elif (title and i == 1
|
|
530
|
-
|
|
1709
|
+
elif (title and i == 1
|
|
1710
|
+
# 副标题 = 紧跟在标题下方、与标题左对齐的那一块。三个量都相对标题
|
|
1711
|
+
# 自身:绝对 px 门槛在大字号排版的模板上会整片认不出来。
|
|
1712
|
+
and abs(b.get('x', 0) - title['box'].get('x', 0)) <= title['box'].get('h', 0)
|
|
531
1713
|
and 0 <= b.get('y', 0) - (title['box'].get('y', 0)
|
|
532
|
-
+ title['box'].get('h', 0))
|
|
1714
|
+
+ title['box'].get('h', 0))
|
|
1715
|
+
<= title['box'].get('h', 0) * 2):
|
|
533
1716
|
role = typ = 'subtitle'
|
|
534
1717
|
else:
|
|
535
1718
|
role = typ = 'body'
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
1719
|
+
row = {'role': role, 'box': [round(b.get('x', 0)), round(b.get('y', 0)),
|
|
1720
|
+
round(b.get('w', 0)), round(b.get('h', 0))],
|
|
1721
|
+
'type': typ, 'sz': t['sz'], 'txt': t['txt']}
|
|
1722
|
+
row.update(t.get('style') or {})
|
|
1723
|
+
if t.get('needs_role'):
|
|
1724
|
+
row.update({
|
|
1725
|
+
'_needs_role': True,
|
|
1726
|
+
'_source_layer': t.get('source_layer'),
|
|
1727
|
+
'_placeholder': t.get('placeholder'),
|
|
1728
|
+
})
|
|
1729
|
+
slots.append(row)
|
|
1730
|
+
# 同组页面上的图片元素按素材+位置去重后落候选 slots。内容图去掉具体资产引用,
|
|
1731
|
+
# 保留通用图片槽;装饰图绑定资产,避免非代表页上的装饰没有进入 layouts。
|
|
1732
|
+
seen_mark = set()
|
|
1733
|
+
for page in ps:
|
|
1734
|
+
for mk in page.get('marks') or []:
|
|
1735
|
+
b = mk['box']
|
|
1736
|
+
key = (mk['media'], round(b.get('x', 0)), round(b.get('y', 0)))
|
|
1737
|
+
if key in seen_mark:
|
|
1738
|
+
continue
|
|
1739
|
+
seen_mark.add(key)
|
|
1740
|
+
slots.append({'role': 'logo', 'type': 'pic', 'sz': 0, 'txt': '',
|
|
1741
|
+
'media': mk['media'],
|
|
1742
|
+
'box': [round(b.get('x', 0)), round(b.get('y', 0)),
|
|
1743
|
+
round(b.get('w', 0)), round(b.get('h', 0))]})
|
|
1744
|
+
taken = {tuple(s['box']) for s in slots}
|
|
1745
|
+
decor = []
|
|
1746
|
+
background_layer = background_decor(rep.get('background'), (cW, cH))
|
|
1747
|
+
if background_layer:
|
|
1748
|
+
decor.append(background_layer)
|
|
1749
|
+
seen_decor = set()
|
|
1750
|
+
for source_part in (rep.get('layout'), rep['part']):
|
|
1751
|
+
for item in collect_decor(shapes, source_part, taken, (cW, cH)):
|
|
1752
|
+
key = (tuple(item['box']), item['geom'], item['css'])
|
|
1753
|
+
if key not in seen_decor:
|
|
1754
|
+
seen_decor.add(key)
|
|
1755
|
+
decor.append(item)
|
|
539
1756
|
archetypes.append({'name': name, 'bg': None, 'bg_raw': bg_raw, 'slots': slots,
|
|
1757
|
+
'decor': decor,
|
|
540
1758
|
'pages': sorted(p['no'] for p in ps), 'rep': rep['no'],
|
|
541
1759
|
'pic_n': rep['pic_n'],
|
|
1760
|
+
'_source_layouts': sorted({
|
|
1761
|
+
p['layout'] for p in ps if p.get('layout')
|
|
1762
|
+
}),
|
|
1763
|
+
'_source_backgrounds': sorted({
|
|
1764
|
+
background_identity(
|
|
1765
|
+
p.get('rendered_bg') or p.get('bg_media')
|
|
1766
|
+
or p.get('background') or p.get('bg_color')
|
|
1767
|
+
)
|
|
1768
|
+
for p in ps
|
|
1769
|
+
if (p.get('rendered_bg') or p.get('bg_media')
|
|
1770
|
+
or p.get('background') or p.get('bg_color'))
|
|
1771
|
+
}),
|
|
1772
|
+
'_text_n': len(rep['texts']),
|
|
1773
|
+
'_last_page_candidate': rep['no'] == last_page_no,
|
|
542
1774
|
'confidence': 'high' if len(ps) >= 3 else
|
|
543
1775
|
('medium' if len(ps) == 2 else 'low')})
|
|
544
1776
|
return archetypes, pages, leftover
|
|
@@ -547,15 +1779,24 @@ def draft_layouts(d, outdir):
|
|
|
547
1779
|
# ---------------------------------------------------------------- 联系表
|
|
548
1780
|
def layout_sheet(outdir, archetypes, path):
|
|
549
1781
|
"""把各 archetype 的代表页光栅出来拼成一张——版式命名得看得见页面。"""
|
|
550
|
-
|
|
1782
|
+
use_layout = all(a.get('rep') is None for a in archetypes)
|
|
1783
|
+
reps = [a.get('rep_layout') if use_layout else a.get('rep') for a in archetypes]
|
|
1784
|
+
reps = [x for x in reps if x is not None]
|
|
551
1785
|
if not reps:
|
|
552
1786
|
return None
|
|
553
|
-
import subprocess
|
|
554
|
-
r = subprocess.run([sys.executable, os.path.join(HERE, 'render_pages.py'), outdir,
|
|
555
|
-
'--pages', 'slides', '--only', ','.join(map(str, reps)), '--no-html'],
|
|
556
|
-
capture_output=True, text=True)
|
|
557
1787
|
png_dir = os.path.join(outdir, 'ref', 'rebuild', 'png')
|
|
558
|
-
if
|
|
1788
|
+
kind = 'layout' if use_layout else 'slide'
|
|
1789
|
+
missing = [no for no in reps
|
|
1790
|
+
if not os.path.exists(os.path.join(png_dir, '%s-%s.png' % (kind, no)))]
|
|
1791
|
+
if missing:
|
|
1792
|
+
import subprocess
|
|
1793
|
+
r = subprocess.run([sys.executable, os.path.join(HERE, 'render_pages.py'), outdir,
|
|
1794
|
+
'--pages', 'layouts' if use_layout else 'slides',
|
|
1795
|
+
'--only', ','.join(map(str, missing)), '--no-html'],
|
|
1796
|
+
capture_output=True, text=True)
|
|
1797
|
+
if r.returncode:
|
|
1798
|
+
return None
|
|
1799
|
+
if not os.path.isdir(png_dir):
|
|
559
1800
|
return None
|
|
560
1801
|
try:
|
|
561
1802
|
from PIL import Image, ImageDraw
|
|
@@ -570,26 +1811,31 @@ def layout_sheet(outdir, archetypes, path):
|
|
|
570
1811
|
for i, a in enumerate(archetypes):
|
|
571
1812
|
x = pad + (i % cols) * (cw + pad)
|
|
572
1813
|
y = pad + (i // cols) * (ch + pad + lab)
|
|
573
|
-
|
|
1814
|
+
no = a.get('rep_layout') if use_layout else a.get('rep')
|
|
1815
|
+
f = os.path.join(png_dir, '%s-%s.png' % (kind, no))
|
|
574
1816
|
if os.path.exists(f):
|
|
575
1817
|
im = Image.open(f).convert('RGB')
|
|
576
1818
|
im.thumbnail((cw, ch))
|
|
577
1819
|
sheet.paste(im, (x, y))
|
|
578
1820
|
dr.rectangle([x, y, x + cw, y + ch], outline=(120, 120, 128))
|
|
579
|
-
|
|
580
|
-
|
|
1821
|
+
# 标注只写 ASCII——Pillow 默认字体没有 CJK 字形,中文会渲染成方框
|
|
1822
|
+
dr.text((x + 2, y + ch + 5), '[%s] %s bg=%s'
|
|
1823
|
+
% (a['name'],
|
|
1824
|
+
('layout %s' % a.get('rep_layout')) if use_layout
|
|
1825
|
+
else ('slide %s x%d pages' % (a.get('rep'), len(a['pages']))),
|
|
1826
|
+
a.get('bg') or '-'),
|
|
581
1827
|
fill=(20, 20, 24))
|
|
582
1828
|
sheet.save(path, optimize=True)
|
|
583
1829
|
return path
|
|
584
1830
|
|
|
585
1831
|
|
|
586
|
-
def contact_sheet(outdir, cands, path):
|
|
1832
|
+
def contact_sheet(outdir, cands, path, start_index=1):
|
|
587
1833
|
try:
|
|
588
1834
|
from PIL import Image, ImageDraw
|
|
589
1835
|
except Exception:
|
|
590
1836
|
return None
|
|
591
1837
|
cell, pad, cols = 220, 20, 4
|
|
592
|
-
items = cands
|
|
1838
|
+
items = cands # 上限由调用方定,编号与 BRIEF 表格一一对应
|
|
593
1839
|
if not items:
|
|
594
1840
|
return None
|
|
595
1841
|
rows = (len(items) + cols - 1) // cols
|
|
@@ -613,19 +1859,548 @@ def contact_sheet(outdir, cands, path):
|
|
|
613
1859
|
dr.text((x + 8, y + 8), 'unreadable', fill=(200, 0, 0))
|
|
614
1860
|
dr.rectangle([x, y, x + cell, y + cell], outline=(120, 120, 128))
|
|
615
1861
|
dr.text((x + 2, y + cell + 4), '[%d] %s %dx%d used=%d'
|
|
616
|
-
% (
|
|
1862
|
+
% (c.get('_candidate_index', start_index + idx), c['file'],
|
|
1863
|
+
c['probe'].get('w') or 0,
|
|
1864
|
+
c['probe'].get('h') or 0, c['n']),
|
|
617
1865
|
fill=(20, 20, 24))
|
|
618
1866
|
sheet.save(path, optimize=True)
|
|
619
1867
|
return path
|
|
620
1868
|
|
|
621
1869
|
|
|
1870
|
+
def contact_sheets(outdir, cands, ldir):
|
|
1871
|
+
paths = []
|
|
1872
|
+
legacy = os.path.join(ldir, 'contact-sheet.png')
|
|
1873
|
+
if os.path.exists(legacy):
|
|
1874
|
+
os.remove(legacy)
|
|
1875
|
+
for start in range(0, len(cands), SHEET_BATCH):
|
|
1876
|
+
batch = cands[start:start + SHEET_BATCH]
|
|
1877
|
+
path = os.path.join(ldir, 'contact-sheet-%d.png' % (start // SHEET_BATCH + 1))
|
|
1878
|
+
if contact_sheet(outdir, batch, path, start + 1):
|
|
1879
|
+
paths.append(path)
|
|
1880
|
+
if paths:
|
|
1881
|
+
shutil.copy2(paths[0], legacy)
|
|
1882
|
+
return paths
|
|
1883
|
+
|
|
1884
|
+
|
|
1885
|
+
def asset_vision_contexts(candidates):
|
|
1886
|
+
"""把同一素材的每个归纳页型实例放进对应语境,不只展示最早出现的页面。"""
|
|
1887
|
+
contexts = []
|
|
1888
|
+
for candidate in candidates:
|
|
1889
|
+
placements = candidate.get('placements') or []
|
|
1890
|
+
instance_placements = [row for row in placements if row.get('slide')]
|
|
1891
|
+
if not instance_placements:
|
|
1892
|
+
row = dict(candidate)
|
|
1893
|
+
row['source_placements'] = placements
|
|
1894
|
+
contexts.append(row)
|
|
1895
|
+
continue
|
|
1896
|
+
seen = set()
|
|
1897
|
+
id_counts = Counter()
|
|
1898
|
+
for placement in instance_placements:
|
|
1899
|
+
box = tuple(placement['box'])
|
|
1900
|
+
layout = placement.get('archetype')
|
|
1901
|
+
key = (layout, box) if layout else (placement['slide'], box)
|
|
1902
|
+
if key in seen:
|
|
1903
|
+
continue
|
|
1904
|
+
seen.add(key)
|
|
1905
|
+
row = dict(candidate)
|
|
1906
|
+
base_id = '%s-s%d' % (candidate['id'], placement['slide'])
|
|
1907
|
+
id_counts[base_id] += 1
|
|
1908
|
+
row['id'] = (base_id if id_counts[base_id] == 1
|
|
1909
|
+
else '%s-%d' % (base_id, id_counts[base_id]))
|
|
1910
|
+
row['placements'] = [placement]
|
|
1911
|
+
row['slides'] = [placement['slide']]
|
|
1912
|
+
row['layout'] = layout
|
|
1913
|
+
row['source_placements'] = placements
|
|
1914
|
+
contexts.append(row)
|
|
1915
|
+
return contexts
|
|
1916
|
+
|
|
1917
|
+
|
|
1918
|
+
def _group_input_count(group):
|
|
1919
|
+
# 与 FaaS 一致:每页桶预留一张页面语境图;无实例页的版式候选也占一个图位。
|
|
1920
|
+
page_count = len([page for page in group['pages'] if page > 0]) or 1
|
|
1921
|
+
return len(group['candidates']) + page_count
|
|
1922
|
+
|
|
1923
|
+
|
|
1924
|
+
def build_asset_vision_groups(candidates):
|
|
1925
|
+
"""按 FaaS 的 10/5 图数预算,把候选按所在页组合成视觉判断批次。"""
|
|
1926
|
+
buckets = defaultdict(list)
|
|
1927
|
+
for candidate in asset_vision_contexts(candidates):
|
|
1928
|
+
page = next((row['slide'] for row in candidate.get('placements') or []
|
|
1929
|
+
if row.get('slide')), 0)
|
|
1930
|
+
buckets[page].append(candidate)
|
|
1931
|
+
|
|
1932
|
+
groups, queued = [], []
|
|
1933
|
+
|
|
1934
|
+
def flush_small_pages():
|
|
1935
|
+
if not queued:
|
|
1936
|
+
return
|
|
1937
|
+
current, current_pages, inputs = [], [], 0
|
|
1938
|
+
for page, page_candidates in queued:
|
|
1939
|
+
page_inputs = 1 + len(page_candidates)
|
|
1940
|
+
if current and inputs + page_inputs > MULTI_PAGE_IMAGE_BUDGET:
|
|
1941
|
+
groups.append({'pages': current_pages, 'candidates': current})
|
|
1942
|
+
current, current_pages, inputs = [], [], 0
|
|
1943
|
+
current.extend(page_candidates)
|
|
1944
|
+
current_pages.append(page)
|
|
1945
|
+
inputs += page_inputs
|
|
1946
|
+
if current:
|
|
1947
|
+
groups.append({'pages': current_pages, 'candidates': current})
|
|
1948
|
+
del queued[:]
|
|
1949
|
+
|
|
1950
|
+
for page in sorted(buckets):
|
|
1951
|
+
page_candidates = buckets[page]
|
|
1952
|
+
page_inputs = 1 + len(page_candidates)
|
|
1953
|
+
if page_inputs > MULTI_PAGE_IMAGE_BUDGET:
|
|
1954
|
+
flush_small_pages()
|
|
1955
|
+
per_group = SINGLE_PAGE_IMAGE_BUDGET - 1
|
|
1956
|
+
for start in range(0, len(page_candidates), per_group):
|
|
1957
|
+
groups.append({
|
|
1958
|
+
'pages': [page],
|
|
1959
|
+
'candidates': page_candidates[start:start + per_group],
|
|
1960
|
+
})
|
|
1961
|
+
continue
|
|
1962
|
+
queued.append((page, page_candidates))
|
|
1963
|
+
flush_small_pages()
|
|
1964
|
+
|
|
1965
|
+
for index, group in enumerate(groups, 1):
|
|
1966
|
+
group['id'] = 'vision-%d' % index
|
|
1967
|
+
group['input_count'] = _group_input_count(group)
|
|
1968
|
+
return groups
|
|
1969
|
+
|
|
1970
|
+
|
|
1971
|
+
def select_asset_vision_groups(groups, slide_count):
|
|
1972
|
+
"""受总预算约束选择视觉批次:首页和尾页的所有可容纳分批优先于中间页。"""
|
|
1973
|
+
if not groups:
|
|
1974
|
+
return [], []
|
|
1975
|
+
first_page = 1
|
|
1976
|
+
last_page = slide_count or max(
|
|
1977
|
+
(page for group in groups for page in group['pages'] if page > 0), default=0)
|
|
1978
|
+
selected, selected_ids, inputs = [], set(), 0
|
|
1979
|
+
|
|
1980
|
+
def add(group):
|
|
1981
|
+
nonlocal inputs
|
|
1982
|
+
if (group['id'] in selected_ids or len(selected) >= VISUAL_PACK_CAP
|
|
1983
|
+
or inputs + group['input_count'] > VISUAL_INPUT_CAP):
|
|
1984
|
+
return False
|
|
1985
|
+
selected.append(group)
|
|
1986
|
+
selected_ids.add(group['id'])
|
|
1987
|
+
inputs += group['input_count']
|
|
1988
|
+
return True
|
|
1989
|
+
|
|
1990
|
+
# 首尾页先于中间页保留全部可容纳分批。交错加入避免首页多批先占满总预算,尾页
|
|
1991
|
+
# 连首批都进不去;单页 deck 不重复扫描。
|
|
1992
|
+
priority_batches = [
|
|
1993
|
+
[group for group in groups if page in group['pages']]
|
|
1994
|
+
for page in dict.fromkeys((first_page, last_page))
|
|
1995
|
+
]
|
|
1996
|
+
for batch_index in range(max(map(len, priority_batches), default=0)):
|
|
1997
|
+
for batches in priority_batches:
|
|
1998
|
+
if batch_index < len(batches):
|
|
1999
|
+
add(batches[batch_index])
|
|
2000
|
+
|
|
2001
|
+
# 首尾的第一个批次已经保证;剩余按页码保留前段内容,优先丢弃尾页之前的后段。
|
|
2002
|
+
remainder = sorted(
|
|
2003
|
+
(group for group in groups if group['id'] not in selected_ids),
|
|
2004
|
+
key=lambda group: (
|
|
2005
|
+
min((page for page in group['pages'] if page > 0), default=999999),
|
|
2006
|
+
group['id'],
|
|
2007
|
+
),
|
|
2008
|
+
)
|
|
2009
|
+
for group in remainder:
|
|
2010
|
+
add(group)
|
|
2011
|
+
|
|
2012
|
+
selected.sort(key=lambda group: (
|
|
2013
|
+
min((page for page in group['pages'] if page > 0), default=999999), group['id']))
|
|
2014
|
+
omitted = [group for group in groups if group['id'] not in selected_ids]
|
|
2015
|
+
return selected, omitted
|
|
2016
|
+
|
|
2017
|
+
|
|
2018
|
+
def _safe_remove(pattern):
|
|
2019
|
+
for path in glob.glob(pattern):
|
|
2020
|
+
try:
|
|
2021
|
+
os.remove(path)
|
|
2022
|
+
except OSError:
|
|
2023
|
+
pass
|
|
2024
|
+
|
|
2025
|
+
|
|
2026
|
+
def _fit_image(image, width, height):
|
|
2027
|
+
copy = image.copy()
|
|
2028
|
+
copy.thumbnail((width, height))
|
|
2029
|
+
return copy
|
|
2030
|
+
|
|
2031
|
+
|
|
2032
|
+
def _draw_checkerboard(draw, box, size=14):
|
|
2033
|
+
x, y, w, h = box
|
|
2034
|
+
for row in range(0, h, size):
|
|
2035
|
+
for col in range(0, w, size):
|
|
2036
|
+
if (row // size + col // size) % 2 == 0:
|
|
2037
|
+
draw.rectangle([x + col, y + row, x + col + size - 1, y + row + size - 1],
|
|
2038
|
+
fill=(214, 214, 218))
|
|
2039
|
+
|
|
2040
|
+
|
|
2041
|
+
def _candidate_is_visual_risk(candidate):
|
|
2042
|
+
probe = candidate.get('probe') or {}
|
|
2043
|
+
alpha = candidate.get('effective_alpha_mean')
|
|
2044
|
+
if alpha is None:
|
|
2045
|
+
alpha = probe.get('alpha_mean')
|
|
2046
|
+
return ((alpha is not None and alpha < 230)
|
|
2047
|
+
or (probe.get('near_white_ratio') or 0) >= 0.7)
|
|
2048
|
+
|
|
2049
|
+
|
|
2050
|
+
def _paste_candidate_preview(sheet, draw, image, box, dark=False):
|
|
2051
|
+
x, y, w, h = box
|
|
2052
|
+
if dark:
|
|
2053
|
+
draw.rectangle([x, y, x + w, y + h], fill=(54, 54, 58))
|
|
2054
|
+
else:
|
|
2055
|
+
_draw_checkerboard(draw, box)
|
|
2056
|
+
preview = _fit_image(image.convert('RGBA'), w - 8, h - 8)
|
|
2057
|
+
px = x + (w - preview.width) // 2
|
|
2058
|
+
py = y + (h - preview.height) // 2
|
|
2059
|
+
sheet.paste(preview, (px, py), preview)
|
|
2060
|
+
|
|
2061
|
+
|
|
2062
|
+
def _placement_text(candidate):
|
|
2063
|
+
rows = []
|
|
2064
|
+
for placement in candidate.get('placements') or []:
|
|
2065
|
+
box = placement['box']
|
|
2066
|
+
if placement.get('slide'):
|
|
2067
|
+
rows.append('s%d@%d,%d,%d,%d' % (
|
|
2068
|
+
placement['slide'], box[0], box[1], box[2], box[3]))
|
|
2069
|
+
else:
|
|
2070
|
+
rows.append('%s@%d,%d,%d,%d' % (
|
|
2071
|
+
placement.get('layout') or 'layout', box[0], box[1], box[2], box[3]))
|
|
2072
|
+
return ';'.join(rows)
|
|
2073
|
+
|
|
2074
|
+
|
|
2075
|
+
def _save_visual_sheet(sheet, path):
|
|
2076
|
+
if max(sheet.size) > VISUAL_PREVIEW_MAX_EDGE:
|
|
2077
|
+
ratio = VISUAL_PREVIEW_MAX_EDGE / float(max(sheet.size))
|
|
2078
|
+
sheet = sheet.resize((max(1, round(sheet.width * ratio)),
|
|
2079
|
+
max(1, round(sheet.height * ratio))))
|
|
2080
|
+
sheet.save(path, 'JPEG', quality=VISUAL_JPEG_QUALITY, optimize=True, progressive=True)
|
|
2081
|
+
|
|
2082
|
+
|
|
2083
|
+
def render_asset_vision_pages(outdir, pages):
|
|
2084
|
+
"""视觉判断必须有页面语境;截图失败时中止草案而非让模型盲判。"""
|
|
2085
|
+
if not pages:
|
|
2086
|
+
return None
|
|
2087
|
+
result = subprocess.run(
|
|
2088
|
+
[sys.executable, os.path.join(HERE, 'render_pages.py'), outdir,
|
|
2089
|
+
'--pages', 'slides', '--only', ','.join(map(str, pages)), '--no-html'],
|
|
2090
|
+
capture_output=True, text=True,
|
|
2091
|
+
)
|
|
2092
|
+
png_dir = os.path.join(outdir, 'ref', 'rebuild', 'png')
|
|
2093
|
+
missing = [
|
|
2094
|
+
page for page in pages
|
|
2095
|
+
if not os.path.isfile(os.path.join(png_dir, 'slide-%d.png' % page))
|
|
2096
|
+
]
|
|
2097
|
+
if result.returncode or missing:
|
|
2098
|
+
detail = (result.stderr or result.stdout or '').strip().splitlines()
|
|
2099
|
+
raise RuntimeError(
|
|
2100
|
+
'视觉判断所需页面截图生成失败%s%s' % (
|
|
2101
|
+
'(缺第%s页)' % '、'.join(map(str, missing)) if missing else '',
|
|
2102
|
+
':' + detail[-1] if detail else '',
|
|
2103
|
+
)
|
|
2104
|
+
)
|
|
2105
|
+
return png_dir
|
|
2106
|
+
|
|
2107
|
+
|
|
2108
|
+
class VisionContextError(RuntimeError):
|
|
2109
|
+
pass
|
|
2110
|
+
|
|
2111
|
+
|
|
2112
|
+
def has_pillow():
|
|
2113
|
+
try:
|
|
2114
|
+
from PIL import Image # noqa: F401
|
|
2115
|
+
except Exception:
|
|
2116
|
+
return False
|
|
2117
|
+
return True
|
|
2118
|
+
|
|
2119
|
+
|
|
2120
|
+
def asset_vision_group_sheet(outdir, group, png_dir, path):
|
|
2121
|
+
"""把一组整页语境和候选图做成可索引拼版;每张候选保持独立卡片。"""
|
|
2122
|
+
try:
|
|
2123
|
+
from PIL import Image, ImageDraw
|
|
2124
|
+
except Exception:
|
|
2125
|
+
return None
|
|
2126
|
+
|
|
2127
|
+
candidates = group['candidates']
|
|
2128
|
+
page_count = len([page for page in group['pages'] if page > 0])
|
|
2129
|
+
page_w, page_h = 440, 248
|
|
2130
|
+
cell, pad, label_h = 220, 16, 42
|
|
2131
|
+
asset_cols = 3
|
|
2132
|
+
asset_rows = max(1, (len(candidates) + asset_cols - 1) // asset_cols)
|
|
2133
|
+
page_rows = max(1, (page_count + 1) // 2) if page_count else 0
|
|
2134
|
+
page_cols = min(2, page_count) if page_count else 0
|
|
2135
|
+
width = max(2 * (page_w + pad) + pad if page_cols else 0,
|
|
2136
|
+
asset_cols * (cell + pad) + pad)
|
|
2137
|
+
header_h = 28
|
|
2138
|
+
page_area_h = (page_rows * (page_h + label_h + pad) + pad) if page_rows else 0
|
|
2139
|
+
asset_top = header_h + page_area_h
|
|
2140
|
+
height = asset_top + asset_rows * (cell + label_h + pad) + pad
|
|
2141
|
+
sheet = Image.new('RGB', (width, height), (245, 245, 247))
|
|
2142
|
+
draw = ImageDraw.Draw(sheet)
|
|
2143
|
+
draw.text((pad, 7), '%s inputs=%d pages=%s' % (
|
|
2144
|
+
group['id'], group['input_count'],
|
|
2145
|
+
','.join(map(str, group['pages'])) or 'layout'), fill=(20, 20, 24))
|
|
2146
|
+
|
|
2147
|
+
for index, page in enumerate([page for page in group['pages'] if page > 0]):
|
|
2148
|
+
x = pad + (index % 2) * (page_w + pad)
|
|
2149
|
+
y = header_h + (index // 2) * (page_h + label_h + pad)
|
|
2150
|
+
source = (os.path.join(png_dir, 'slide-%d.png' % page)
|
|
2151
|
+
if png_dir else None)
|
|
2152
|
+
if source and os.path.exists(source):
|
|
2153
|
+
try:
|
|
2154
|
+
image = Image.open(source).convert('RGB')
|
|
2155
|
+
image = _fit_image(image, page_w, page_h)
|
|
2156
|
+
sheet.paste(image, (x + (page_w - image.width) // 2,
|
|
2157
|
+
y + (page_h - image.height) // 2))
|
|
2158
|
+
except Exception as exc:
|
|
2159
|
+
raise VisionContextError('视觉判断所需页面截图不可读取:第%d页' % page) from exc
|
|
2160
|
+
else:
|
|
2161
|
+
raise VisionContextError('视觉判断所需页面截图缺失:第%d页' % page)
|
|
2162
|
+
draw.rectangle([x, y, x + page_w, y + page_h], outline=(120, 120, 128))
|
|
2163
|
+
draw.text((x + 2, y + page_h + 5), '[page %d] context for candidates below' % page,
|
|
2164
|
+
fill=(20, 20, 24))
|
|
2165
|
+
|
|
2166
|
+
for index, candidate in enumerate(candidates):
|
|
2167
|
+
x = pad + (index % asset_cols) * (cell + pad)
|
|
2168
|
+
y = asset_top + (index // asset_cols) * (cell + label_h + pad)
|
|
2169
|
+
image_path = os.path.join(outdir, candidate['out'])
|
|
2170
|
+
try:
|
|
2171
|
+
image = Image.open(image_path)
|
|
2172
|
+
if _candidate_is_visual_risk(candidate):
|
|
2173
|
+
half = (cell - 3) // 2
|
|
2174
|
+
_paste_candidate_preview(sheet, draw, image, (x, y, half, cell))
|
|
2175
|
+
_paste_candidate_preview(sheet, draw, image, (x + half + 3, y, cell - half - 3, cell),
|
|
2176
|
+
dark=True)
|
|
2177
|
+
else:
|
|
2178
|
+
_paste_candidate_preview(sheet, draw, image, (x, y, cell, cell))
|
|
2179
|
+
except Exception:
|
|
2180
|
+
draw.text((x + 8, y + 8), 'unreadable', fill=(200, 0, 0))
|
|
2181
|
+
draw.rectangle([x, y, x + cell, y + cell], outline=(120, 120, 128))
|
|
2182
|
+
probe = candidate.get('probe') or {}
|
|
2183
|
+
alpha = candidate.get('effective_alpha_mean')
|
|
2184
|
+
if alpha is None:
|
|
2185
|
+
alpha = probe.get('alpha_mean')
|
|
2186
|
+
risk = (' a=%s w=%s' % (
|
|
2187
|
+
'?' if alpha is None else round(alpha),
|
|
2188
|
+
'?' if probe.get('near_white_ratio') is None
|
|
2189
|
+
else round(probe['near_white_ratio'] * 100),
|
|
2190
|
+
)) if _candidate_is_visual_risk(candidate) else ''
|
|
2191
|
+
draw.text((x + 2, y + cell + 3), '[%s] %s %dx%d%s' % (
|
|
2192
|
+
candidate['id'], candidate['file'], probe.get('w') or 0, probe.get('h') or 0, risk),
|
|
2193
|
+
fill=(20, 20, 24))
|
|
2194
|
+
first = (candidate.get('placements') or [{}])[0]
|
|
2195
|
+
total_placements = len(candidate.get('source_placements') or
|
|
2196
|
+
candidate.get('placements') or [])
|
|
2197
|
+
if first.get('slide'):
|
|
2198
|
+
box = first['box']
|
|
2199
|
+
draw.text((x + 2, y + cell + 18), 's%d @%d,%d %dx%d seen=%d' % (
|
|
2200
|
+
first['slide'], box[0], box[1], box[2], box[3],
|
|
2201
|
+
total_placements), fill=(20, 20, 24))
|
|
2202
|
+
else:
|
|
2203
|
+
draw.text((x + 2, y + cell + 18), 'layout x%d' % len(candidate.get('placements') or []),
|
|
2204
|
+
fill=(20, 20, 24))
|
|
2205
|
+
_save_visual_sheet(sheet, path)
|
|
2206
|
+
return path
|
|
2207
|
+
|
|
2208
|
+
|
|
2209
|
+
def emit_asset_vision_groups(outdir, candidates, slide_count, ldir):
|
|
2210
|
+
"""生成受预算约束的拼版和结构化索引,返回已选/未选组。"""
|
|
2211
|
+
groups = build_asset_vision_groups(candidates)
|
|
2212
|
+
selected, omitted = select_asset_vision_groups(groups, slide_count)
|
|
2213
|
+
_safe_remove(os.path.join(ldir, 'vision-group-*.jpg'))
|
|
2214
|
+
_safe_remove(os.path.join(ldir, 'contact-sheet-*.png'))
|
|
2215
|
+
_safe_remove(os.path.join(ldir, 'contact-sheet.png'))
|
|
2216
|
+
_safe_remove(os.path.join(ldir, 'asset-context-sheet-*.png'))
|
|
2217
|
+
|
|
2218
|
+
paths = []
|
|
2219
|
+
if not has_pillow():
|
|
2220
|
+
omitted = groups
|
|
2221
|
+
selected = []
|
|
2222
|
+
else:
|
|
2223
|
+
pages = sorted({page for group in selected for page in group['pages'] if page > 0})
|
|
2224
|
+
png_dir = render_asset_vision_pages(outdir, pages)
|
|
2225
|
+
try:
|
|
2226
|
+
for index, group in enumerate(selected, 1):
|
|
2227
|
+
path = os.path.join(ldir, 'vision-group-%d.jpg' % index)
|
|
2228
|
+
if asset_vision_group_sheet(outdir, group, png_dir, path):
|
|
2229
|
+
paths.append(path)
|
|
2230
|
+
group['sheet'] = os.path.basename(path)
|
|
2231
|
+
if selected and len(paths) != len(selected):
|
|
2232
|
+
raise RuntimeError('视觉判断拼版生成失败')
|
|
2233
|
+
except VisionContextError:
|
|
2234
|
+
raise
|
|
2235
|
+
except Exception:
|
|
2236
|
+
_safe_remove(os.path.join(ldir, 'vision-group-*.jpg'))
|
|
2237
|
+
selected, omitted, paths = [], groups, []
|
|
2238
|
+
if paths:
|
|
2239
|
+
# 旧流程只认 contact-sheet.png;保留首个视觉组的 PNG 别名,新的 BRIEF 不再要求读它。
|
|
2240
|
+
try:
|
|
2241
|
+
from PIL import Image
|
|
2242
|
+
legacy = os.path.join(ldir, 'contact-sheet-1.png')
|
|
2243
|
+
Image.open(paths[0]).convert('RGB').save(legacy, 'PNG', optimize=True)
|
|
2244
|
+
shutil.copy2(legacy, os.path.join(ldir, 'contact-sheet.png'))
|
|
2245
|
+
except Exception:
|
|
2246
|
+
pass
|
|
2247
|
+
|
|
2248
|
+
def serialize(group):
|
|
2249
|
+
return {
|
|
2250
|
+
'id': group['id'],
|
|
2251
|
+
'sheet': group.get('sheet'),
|
|
2252
|
+
'pages': group['pages'],
|
|
2253
|
+
'input_count': group['input_count'],
|
|
2254
|
+
'candidates': [{
|
|
2255
|
+
'id': candidate['id'],
|
|
2256
|
+
'source_media': candidate['file'],
|
|
2257
|
+
'source_px': [candidate['probe'].get('w'), candidate['probe'].get('h')],
|
|
2258
|
+
'bytes': candidate.get('bytes'),
|
|
2259
|
+
'repeat_count': candidate.get('n'),
|
|
2260
|
+
'fullscreen': candidate.get('fullscreen'),
|
|
2261
|
+
'effective_alpha_mean': candidate.get('effective_alpha_mean'),
|
|
2262
|
+
'near_white_ratio': candidate['probe'].get('near_white_ratio'),
|
|
2263
|
+
'placements': candidate.get('placements') or [],
|
|
2264
|
+
'source_placements': candidate.get('source_placements') or
|
|
2265
|
+
candidate.get('placements') or [],
|
|
2266
|
+
} for candidate in group['candidates']],
|
|
2267
|
+
}
|
|
2268
|
+
|
|
2269
|
+
index = {
|
|
2270
|
+
'version': 2,
|
|
2271
|
+
'limits': {
|
|
2272
|
+
'single_page_image_budget': SINGLE_PAGE_IMAGE_BUDGET,
|
|
2273
|
+
'multi_page_image_budget': MULTI_PAGE_IMAGE_BUDGET,
|
|
2274
|
+
'pack_cap': VISUAL_PACK_CAP,
|
|
2275
|
+
'input_cap': VISUAL_INPUT_CAP,
|
|
2276
|
+
},
|
|
2277
|
+
'selected': [serialize(group) for group in selected],
|
|
2278
|
+
'omitted': [serialize(group) for group in omitted],
|
|
2279
|
+
}
|
|
2280
|
+
with open(os.path.join(ldir, 'asset-vision-groups.json'), 'w', encoding='utf-8') as stream:
|
|
2281
|
+
json.dump(index, stream, ensure_ascii=False, indent=2)
|
|
2282
|
+
stream.write('\n')
|
|
2283
|
+
return selected, omitted, paths
|
|
2284
|
+
|
|
2285
|
+
|
|
2286
|
+
def asset_context_sheets(outdir, cands, ldir):
|
|
2287
|
+
"""按候选主所在页去重拼整页语境,供模型识别 logo 墙和装饰用途。"""
|
|
2288
|
+
reviewed = [c for c in cands if needs_asset_judgment(c)]
|
|
2289
|
+
pages = []
|
|
2290
|
+
seen = set()
|
|
2291
|
+
for c in reviewed:
|
|
2292
|
+
page = next((no for no in c.get('slides') or [] if no and no != 9999), None)
|
|
2293
|
+
if page is not None and page not in seen:
|
|
2294
|
+
seen.add(page)
|
|
2295
|
+
pages.append(page)
|
|
2296
|
+
if not pages:
|
|
2297
|
+
return []
|
|
2298
|
+
import subprocess
|
|
2299
|
+
result = subprocess.run(
|
|
2300
|
+
[sys.executable, os.path.join(HERE, 'render_pages.py'), outdir,
|
|
2301
|
+
'--pages', 'slides', '--only', ','.join(map(str, pages)), '--no-html'],
|
|
2302
|
+
capture_output=True, text=True,
|
|
2303
|
+
)
|
|
2304
|
+
png_dir = os.path.join(outdir, 'ref', 'rebuild', 'png')
|
|
2305
|
+
if result.returncode or not os.path.isdir(png_dir):
|
|
2306
|
+
return []
|
|
2307
|
+
try:
|
|
2308
|
+
from PIL import Image, ImageDraw
|
|
2309
|
+
except Exception:
|
|
2310
|
+
return []
|
|
2311
|
+
paths = []
|
|
2312
|
+
candidate_ids = defaultdict(list)
|
|
2313
|
+
for index, c in enumerate(cands, 1):
|
|
2314
|
+
if not needs_asset_judgment(c):
|
|
2315
|
+
continue
|
|
2316
|
+
for page in c.get('slides') or []:
|
|
2317
|
+
if page in seen:
|
|
2318
|
+
candidate_ids[page].append(index)
|
|
2319
|
+
for start in range(0, len(pages), CONTEXT_BATCH):
|
|
2320
|
+
batch = pages[start:start + CONTEXT_BATCH]
|
|
2321
|
+
cols, cw, ch, pad, lab = 2, 480, 270, 16, 22
|
|
2322
|
+
rows = (len(batch) + cols - 1) // cols
|
|
2323
|
+
sheet = Image.new('RGB', (cols * (cw + pad) + pad,
|
|
2324
|
+
rows * (ch + pad + lab) + pad), (245, 245, 247))
|
|
2325
|
+
draw = ImageDraw.Draw(sheet)
|
|
2326
|
+
for offset, page in enumerate(batch):
|
|
2327
|
+
x = pad + (offset % cols) * (cw + pad)
|
|
2328
|
+
y = pad + (offset // cols) * (ch + pad + lab)
|
|
2329
|
+
source = os.path.join(png_dir, 'slide-%d.png' % page)
|
|
2330
|
+
if os.path.exists(source):
|
|
2331
|
+
image = Image.open(source).convert('RGB')
|
|
2332
|
+
image.thumbnail((cw, ch))
|
|
2333
|
+
sheet.paste(image, (x, y))
|
|
2334
|
+
draw.rectangle([x, y, x + cw, y + ch], outline=(120, 120, 128))
|
|
2335
|
+
draw.text((x + 2, y + ch + 5), 'slide %d candidates=%s'
|
|
2336
|
+
% (page, ','.join(map(str, candidate_ids[page]))),
|
|
2337
|
+
fill=(20, 20, 24))
|
|
2338
|
+
path = os.path.join(ldir, 'asset-context-sheet-%d.png'
|
|
2339
|
+
% (start // CONTEXT_BATCH + 1))
|
|
2340
|
+
sheet.save(path, optimize=True)
|
|
2341
|
+
paths.append(path)
|
|
2342
|
+
return paths
|
|
2343
|
+
|
|
2344
|
+
|
|
622
2345
|
# ---------------------------------------------------------------- 落盘
|
|
623
2346
|
def write(p, s):
|
|
624
2347
|
with open(p, 'w', encoding='utf-8') as f:
|
|
625
2348
|
f.write(s)
|
|
626
2349
|
|
|
627
2350
|
|
|
628
|
-
def
|
|
2351
|
+
def bound_visual_candidates(candidates, archetypes):
|
|
2352
|
+
"""只把最终有图片槽的候选交给判断单;其余仍留在联系表供视觉核对。"""
|
|
2353
|
+
bound_files = {
|
|
2354
|
+
s.get('source_media')
|
|
2355
|
+
for a in archetypes
|
|
2356
|
+
for s in a.get('slots') or []
|
|
2357
|
+
if s.get('source_media')
|
|
2358
|
+
}
|
|
2359
|
+
return [c for c in candidates if c.get('file') in bound_files]
|
|
2360
|
+
|
|
2361
|
+
|
|
2362
|
+
def visual_slot_candidates(candidates, archetypes):
|
|
2363
|
+
"""把候选绑定到最终槽位;页面截图只作该槽位的视觉语境。"""
|
|
2364
|
+
source_candidates = {
|
|
2365
|
+
candidate.get('file'): candidate
|
|
2366
|
+
for candidate in bound_visual_candidates(candidates, archetypes)
|
|
2367
|
+
}
|
|
2368
|
+
rows, seen = [], set()
|
|
2369
|
+
for archetype in archetypes:
|
|
2370
|
+
pages = set(archetype.get('pages') or archetype.get('_sample_pages') or [])
|
|
2371
|
+
for slot in archetype.get('slots') or []:
|
|
2372
|
+
source = slot.get('source_media')
|
|
2373
|
+
raw_box = slot.get('box')
|
|
2374
|
+
candidate = source_candidates.get(source)
|
|
2375
|
+
if not pages or not candidate or not raw_box or not needs_asset_judgment(candidate):
|
|
2376
|
+
continue
|
|
2377
|
+
box = [round(value) for value in raw_box]
|
|
2378
|
+
key = (source, archetype['name'], tuple(box))
|
|
2379
|
+
if key in seen:
|
|
2380
|
+
continue
|
|
2381
|
+
seen.add(key)
|
|
2382
|
+
source_placements = candidate.get('placements') or []
|
|
2383
|
+
matching = [
|
|
2384
|
+
placement for placement in source_placements
|
|
2385
|
+
if tuple(placement.get('box') or ()) == tuple(box)
|
|
2386
|
+
and placement.get('slide') in pages
|
|
2387
|
+
]
|
|
2388
|
+
if not matching:
|
|
2389
|
+
continue
|
|
2390
|
+
slide = matching[0]['slide']
|
|
2391
|
+
row = dict(candidate)
|
|
2392
|
+
row['placements'] = [{
|
|
2393
|
+
'slide': slide,
|
|
2394
|
+
'box': box,
|
|
2395
|
+
'archetype': archetype['name'],
|
|
2396
|
+
}]
|
|
2397
|
+
row['slides'] = [slide] if slide else []
|
|
2398
|
+
row['source_placements'] = source_placements
|
|
2399
|
+
rows.append(row)
|
|
2400
|
+
return rows
|
|
2401
|
+
|
|
2402
|
+
|
|
2403
|
+
def emit_manifest(d, assets, vision_groups, ldir, archetypes=()):
|
|
629
2404
|
L = ['version: alpha',
|
|
630
2405
|
'name: TODO-style-name # 英文 kebab,体现气质,不要用文件名',
|
|
631
2406
|
'name_zh: TODO中文名',
|
|
@@ -633,7 +2408,9 @@ def emit_manifest(d, assets, ldir):
|
|
|
633
2408
|
' TODO: 一句话说清这套模板的视觉性格(底色 / 主色 / 字形 / 版面骨架),给消费模型定调。']
|
|
634
2409
|
themes = d['theme_topology'].get('themes') or ['single']
|
|
635
2410
|
if themes != ['single'] and len(themes) > 1:
|
|
636
|
-
|
|
2411
|
+
default_theme = d['theme_topology'].get('default') or themes[0]
|
|
2412
|
+
L += ['themes: [%s]' % ', '.join(themes),
|
|
2413
|
+
'default-theme: %s' % default_theme]
|
|
637
2414
|
if assets:
|
|
638
2415
|
L.append('assets:')
|
|
639
2416
|
for a in assets:
|
|
@@ -646,6 +2423,37 @@ def emit_manifest(d, assets, ldir):
|
|
|
646
2423
|
L.append(' on-bg: %s' % (a.get('on_bg') or 'light'))
|
|
647
2424
|
if a['use_full']:
|
|
648
2425
|
L.append(' use_full: true')
|
|
2426
|
+
if vision_groups:
|
|
2427
|
+
L += [
|
|
2428
|
+
'asset_vision_groups:',
|
|
2429
|
+
' # 每项对应拼版中的一个候选实例;同源图在不同页型/位置可分别定性。',
|
|
2430
|
+
' # 取值与 FaaS 对齐:logo|slogan|background|texture|icon|decorative|illustration|photo|chart|screenshot|footer-copyright|page-number|watermark|content-image|unknown。',
|
|
2431
|
+
]
|
|
2432
|
+
for group in vision_groups:
|
|
2433
|
+
for candidate in group['candidates']:
|
|
2434
|
+
placement = (candidate.get('placements') or [{}])[0]
|
|
2435
|
+
L.append(' - id: %s' % candidate['id'])
|
|
2436
|
+
L.append(' source_media: %s' % q(candidate['file']))
|
|
2437
|
+
if placement.get('box'):
|
|
2438
|
+
L.append(' box: %s' % placement['box'])
|
|
2439
|
+
L.append(' visual_kind: TODO-visual-kind-%s # %s;视觉组 %s'
|
|
2440
|
+
% (candidate['id'], candidate['id'], group['id']))
|
|
2441
|
+
L += [
|
|
2442
|
+
'asset_decisions:',
|
|
2443
|
+
' # 仅在不在视觉预算内的图片、或需要覆盖已有判断时追加。',
|
|
2444
|
+
' # 位置例外写 box;同图同坐标跨页型不同,再补 layout。',
|
|
2445
|
+
' # - {source_media: example.png, visual_kind: chart}',
|
|
2446
|
+
' # - {source_media: example.png, layout: layout-2, box: [0, 0, 100, 100], visual_kind: decorative}',
|
|
2447
|
+
]
|
|
2448
|
+
if any(decor.get('trace') == 'canvas-background'
|
|
2449
|
+
for archetype in archetypes
|
|
2450
|
+
for decor in archetype.get('decor') or []):
|
|
2451
|
+
canvas = d['canvas']['px']
|
|
2452
|
+
L += [
|
|
2453
|
+
'derived:',
|
|
2454
|
+
' - value: "[0, 0, %d, %d]"' % (canvas[0], canvas[1]),
|
|
2455
|
+
' reason: "PPT 背景铺满画布"',
|
|
2456
|
+
]
|
|
649
2457
|
write(os.path.join(ldir, 'manifest.yaml'), '\n'.join(L) + '\n')
|
|
650
2458
|
|
|
651
2459
|
|
|
@@ -669,20 +2477,32 @@ def emit_frontmatter(d, tokens, fonts, roles, anchors, gaps, ldir):
|
|
|
669
2477
|
edge = {}
|
|
670
2478
|
for p in pads:
|
|
671
2479
|
edge.setdefault(p['edge'], p['px'])
|
|
672
|
-
|
|
2480
|
+
# 四边都测出来才写 spacing / safe-area。缺一边就整段不写,并在 gaps 说明——
|
|
2481
|
+
# 拿另一套模板的边距当默认值,会让消费端按一个从没在本模板出现过的网格排版。
|
|
2482
|
+
edges_full = all(edge.get(k) is not None for k in ('top', 'right', 'bottom', 'left'))
|
|
2483
|
+
if edges_full:
|
|
673
2484
|
L.append('spacing:')
|
|
674
2485
|
L.append(' page-padding: {top: %s, right: %s, bottom: %s, left: %s}'
|
|
675
|
-
% (edge
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
2486
|
+
% (edge['top'], edge['right'], edge['bottom'], edge['left']))
|
|
2487
|
+
# rounded.card 是全局 token,只能表达全档共同的一档圆角。多个非零档位或零/非零
|
|
2488
|
+
# 混用时,圆角属于 layouts.md 里的局部形状事实,压成一个值会把直角容器也圆角化。
|
|
2489
|
+
radii = d.get('radii_census') or []
|
|
2490
|
+
if len(radii) == 1 and radii[0]['px'] >= 1:
|
|
2491
|
+
top = radii[0]
|
|
680
2492
|
L.append('rounded:')
|
|
681
2493
|
L.append(' card: %dpx' % round(top['px']))
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
2494
|
+
if edges_full:
|
|
2495
|
+
L.append('safe-area:')
|
|
2496
|
+
L.append(' content: {top: %s, right: %s, bottom: %s, left: %s, applies-to: [content]}'
|
|
2497
|
+
% (edge['top'], edge['right'], edge['bottom'], edge['left']))
|
|
2498
|
+
L.append(' confidence: medium')
|
|
2499
|
+
else:
|
|
2500
|
+
gaps = list(gaps) + ['本模板没测出四边都稳定的页边距(普查到 %s),'
|
|
2501
|
+
'因此不给 spacing / safe-area:按各页型 slot 的实际坐标排版,'
|
|
2502
|
+
'不要自造统一边距。'
|
|
2503
|
+
% ('、'.join('%s=%s' % (k, edge[k]) for k in
|
|
2504
|
+
('top', 'right', 'bottom', 'left') if edge.get(k) is not None)
|
|
2505
|
+
or '一边都没有')]
|
|
686
2506
|
L.append('anchors:')
|
|
687
2507
|
for aid, typ, desc in anchors:
|
|
688
2508
|
L.append(' - {id: %s, type: %s, desc: "%s"}' % (aid, typ, desc))
|
|
@@ -692,131 +2512,656 @@ def emit_frontmatter(d, tokens, fonts, roles, anchors, gaps, ldir):
|
|
|
692
2512
|
write(os.path.join(ldir, 'frontmatter.yaml'), '\n'.join(L) + '\n')
|
|
693
2513
|
|
|
694
2514
|
|
|
695
|
-
def
|
|
696
|
-
|
|
697
|
-
|
|
2515
|
+
def draft_flow(a, facts, canvas):
|
|
2516
|
+
"""从结构事实推出「区带」草案:一页 = 若干竖直区带,高度由内容决定。
|
|
2517
|
+
|
|
2518
|
+
绝对坐标只能表达「模板样张那份内容摆在哪」。真实内容长度不同,上面的区带一变高,
|
|
2519
|
+
下面的就该整体下移——这件事在一张坐标表里表达不出来,只能靠消费端自己算,而它
|
|
2520
|
+
算错的方向有两个:估小了压穿下一块,估大了留一片空。
|
|
2521
|
+
|
|
2522
|
+
这里只出草案,最终用绝对还是流式由看得到重建图的人定。
|
|
2523
|
+
"""
|
|
2524
|
+
cW, cH = canvas
|
|
2525
|
+
# 装饰件也算进来:很多模板的版式层只有几个占位符,真正撑起版面的是卡片容器
|
|
2526
|
+
# (在 decor 里)。只看 slots 会把一页的主体结构整个漏掉。
|
|
2527
|
+
slots = [s for s in a['slots'] if s.get('box')]
|
|
2528
|
+
fixed_roles = {'logo', 'slide-number', 'page-number', 'header', 'footer'}
|
|
2529
|
+
fixed = [s for s in slots if s.get('role') in fixed_roles]
|
|
2530
|
+
content_slots = [s for s in slots if s.get('role') not in fixed_roles]
|
|
2531
|
+
containers = [{'role': 'container', 'type': 'decor', 'box': dcr['box'],
|
|
2532
|
+
'css': dcr.get('css')} for dcr in (a.get('decor') or [])]
|
|
2533
|
+
items = group_flow_cards(content_slots, containers)
|
|
2534
|
+
if len(items) < 2:
|
|
2535
|
+
return None
|
|
2536
|
+
items.sort(key=lambda s: (s['box'][1], s['box'][0]))
|
|
2537
|
+
gaps = [items[i + 1]['box'][1] - (items[i]['box'][1] + items[i]['box'][3])
|
|
2538
|
+
for i in range(len(items) - 1)]
|
|
2539
|
+
pos = [g for g in gaps if g > 0]
|
|
2540
|
+
if not pos:
|
|
2541
|
+
return None
|
|
2542
|
+
# 区带边界 = 间距分布里的最大空档。同一区带内部的间距(网格行距之类)总是明显
|
|
2543
|
+
# 小于区带之间的间距,用本页自己的分布切,不设固定阈值。
|
|
2544
|
+
cut = _gap_cut(pos, min(pos), max(pos)) if len(pos) > 1 else pos[0]
|
|
2545
|
+
regions, cur = [], [items[0]]
|
|
2546
|
+
for i, g in enumerate(gaps):
|
|
2547
|
+
if g >= cut:
|
|
2548
|
+
regions.append(cur)
|
|
2549
|
+
cur = []
|
|
2550
|
+
cur.append(items[i + 1])
|
|
2551
|
+
regions.append(cur)
|
|
2552
|
+
|
|
2553
|
+
# 整页左右边距 = 所有内容的横向外包络,作为各区带的缺省。
|
|
2554
|
+
lefts = [s['box'][0] for s in items]
|
|
2555
|
+
rights = [s['box'][0] + s['box'][2] for s in items]
|
|
2556
|
+
page_margin = [min(lefts), cW - max(rights)]
|
|
2557
|
+
|
|
2558
|
+
out = []
|
|
2559
|
+
for reg in regions:
|
|
2560
|
+
if not reg:
|
|
2561
|
+
continue
|
|
2562
|
+
# 同一区带里 y 接近的算一行;每行元素数一致且 >1 就是网格
|
|
2563
|
+
rows, cr = [], [reg[0]]
|
|
2564
|
+
for s in reg[1:]:
|
|
2565
|
+
if abs(s['box'][1] - cr[-1]['box'][1]) <= max(s['box'][3], 1) * 0.5:
|
|
2566
|
+
cr.append(s)
|
|
2567
|
+
else:
|
|
2568
|
+
rows.append(cr)
|
|
2569
|
+
cr = [s]
|
|
2570
|
+
rows.append(cr)
|
|
2571
|
+
widths = {len(r) for r in rows}
|
|
2572
|
+
if len(rows) >= 1 and widths == {len(rows[0])} and len(rows[0]) > 1:
|
|
2573
|
+
cols = len(rows[0])
|
|
2574
|
+
xs = sorted(s['box'][0] for s in rows[0])
|
|
2575
|
+
col_gap = round((xs[1] - xs[0]) - rows[0][0]['box'][2]) if cols > 1 else 0
|
|
2576
|
+
row_gap = 0
|
|
2577
|
+
if len(rows) > 1:
|
|
2578
|
+
row_gap = round(rows[1][0]['box'][1]
|
|
2579
|
+
- (rows[0][0]['box'][1] + rows[0][0]['box'][3]))
|
|
2580
|
+
region = {'kind': 'grid', 'cols': cols, 'gap': [max(col_gap, 0), max(row_gap, 0)],
|
|
2581
|
+
'items': rows[0]}
|
|
2582
|
+
# 卡片组的横向范围常和整页不同(标题贴左、卡片居中)。整页边距是所有元素的
|
|
2583
|
+
# 外包络,直接套给居中卡片组会把它拉偏成左对齐。区带范围和整页明显不一致时,
|
|
2584
|
+
# 落这个区带自己的左右边距,消费端把网格放进它再填 1fr。按落盘的整数比较,
|
|
2585
|
+
# 亚像素噪声不触发多余的区带边距。
|
|
2586
|
+
reg_margin = [min(s['box'][0] for s in rows[0]),
|
|
2587
|
+
cW - max(s['box'][0] + s['box'][2] for s in rows[0])]
|
|
2588
|
+
if [int(reg_margin[0]), int(reg_margin[1])] != [int(page_margin[0]), int(page_margin[1])]:
|
|
2589
|
+
region['margin'] = reg_margin
|
|
2590
|
+
out.append(region)
|
|
2591
|
+
elif len(rows) == len(reg):
|
|
2592
|
+
# 每行一个元素 = 真的竖着排
|
|
2593
|
+
inner = 0
|
|
2594
|
+
if len(reg) > 1:
|
|
2595
|
+
inner = round(reg[1]['box'][1] - (reg[0]['box'][1] + reg[0]['box'][3]))
|
|
2596
|
+
out.append({'kind': 'stack', 'gap': max(inner, 0), 'items': reg})
|
|
2597
|
+
else:
|
|
2598
|
+
# 每行元素数不一致(比如左列两张、右列一张跨两行)。硬说成 stack 会让消费端
|
|
2599
|
+
# 以为它们是竖排的,比不给还糟。如实说这块推不出规整结构,按坐标摆。
|
|
2600
|
+
out.append({'kind': 'free', 'items': reg})
|
|
2601
|
+
if fixed:
|
|
2602
|
+
out.append({'kind': 'free', 'items': fixed})
|
|
2603
|
+
if len(out) < 2:
|
|
2604
|
+
return None
|
|
2605
|
+
return {'top': items[0]['box'][1], 'margin': page_margin,
|
|
2606
|
+
'gap': round(cut), 'regions': out}
|
|
2607
|
+
|
|
2608
|
+
|
|
2609
|
+
def box_contains(outer, inner):
|
|
2610
|
+
return (outer[0] <= inner[0] and outer[1] <= inner[1]
|
|
2611
|
+
and outer[0] + outer[2] >= inner[0] + inner[2]
|
|
2612
|
+
and outer[1] + outer[3] >= inner[1] + inner[3])
|
|
2613
|
+
|
|
2614
|
+
|
|
2615
|
+
def boxes_overlap(a, b):
|
|
2616
|
+
return (min(a[0] + a[2], b[0] + b[2]) > max(a[0], b[0])
|
|
2617
|
+
and min(a[1] + a[3], b[1] + b[3]) > max(a[1], b[1]))
|
|
2618
|
+
|
|
2619
|
+
|
|
2620
|
+
def overlap_ratio(outer, inner):
|
|
2621
|
+
width = min(outer[0] + outer[2], inner[0] + inner[2]) - max(outer[0], inner[0])
|
|
2622
|
+
height = min(outer[1] + outer[3], inner[1] + inner[3]) - max(outer[1], inner[1])
|
|
2623
|
+
if width <= 0 or height <= 0 or inner[2] <= 0 or inner[3] <= 0:
|
|
2624
|
+
return 0
|
|
2625
|
+
return width * height / (inner[2] * inner[3])
|
|
2626
|
+
|
|
2627
|
+
|
|
2628
|
+
def group_flow_cards(slots, containers):
|
|
2629
|
+
"""把并列卡片容器及其文字组成一层 group,避免拍平成多列元素。"""
|
|
2630
|
+
candidates = []
|
|
2631
|
+
for container in containers:
|
|
2632
|
+
children = [slot for slot in slots if box_contains(container['box'], slot['box'])]
|
|
2633
|
+
if len(children) >= 2:
|
|
2634
|
+
candidates.append((container, children))
|
|
2635
|
+
selected = []
|
|
2636
|
+
for container, children in sorted(
|
|
2637
|
+
candidates, key=lambda pair: pair[0]['box'][2] * pair[0]['box'][3]):
|
|
2638
|
+
if not any(boxes_overlap(container['box'], other['box']) for other, _ in selected):
|
|
2639
|
+
selected.append((container, children))
|
|
2640
|
+
if len(selected) < 2:
|
|
2641
|
+
return slots + containers
|
|
2642
|
+
|
|
2643
|
+
grouped_slots = {id(slot) for _, children in selected for slot in children}
|
|
2644
|
+
nested_by_container = {}
|
|
2645
|
+
for container, _ in selected:
|
|
2646
|
+
nested_by_container[id(container)] = [
|
|
2647
|
+
other for other in containers
|
|
2648
|
+
if other is not container and overlap_ratio(container['box'], other['box']) >= 0.9
|
|
2649
|
+
]
|
|
2650
|
+
grouped_containers = {
|
|
2651
|
+
id(container)
|
|
2652
|
+
for container, _ in selected
|
|
2653
|
+
for container in [container] + nested_by_container[id(container)]
|
|
2654
|
+
}
|
|
2655
|
+
out = [slot for slot in slots if id(slot) not in grouped_slots]
|
|
2656
|
+
out += [container for container in containers if id(container) not in grouped_containers]
|
|
2657
|
+
for container, children in selected:
|
|
2658
|
+
children = children + nested_by_container[id(container)]
|
|
2659
|
+
children = sorted(children, key=lambda slot: (slot['box'][1], slot['box'][0]))
|
|
2660
|
+
gaps = [children[i + 1]['box'][1]
|
|
2661
|
+
- (children[i]['box'][1] + children[i]['box'][3])
|
|
2662
|
+
for i in range(len(children) - 1)]
|
|
2663
|
+
outer = container['box']
|
|
2664
|
+
insets = [
|
|
2665
|
+
min(child['box'][1] - outer[1] for child in children),
|
|
2666
|
+
min(outer[0] + outer[2] - child['box'][0] - child['box'][2] for child in children),
|
|
2667
|
+
min(outer[1] + outer[3] - child['box'][1] - child['box'][3] for child in children),
|
|
2668
|
+
min(child['box'][0] - outer[0] for child in children),
|
|
2669
|
+
]
|
|
2670
|
+
padding = max(0, round(min(insets)))
|
|
2671
|
+
css = container.get('css') or ''
|
|
2672
|
+
if padding:
|
|
2673
|
+
css = '; '.join(part for part in (
|
|
2674
|
+
css.rstrip('; '), 'box-sizing: border-box', 'padding: %dpx' % padding) if part)
|
|
2675
|
+
out.append({
|
|
2676
|
+
'role': 'group',
|
|
2677
|
+
'type': 'group',
|
|
2678
|
+
'box': outer,
|
|
2679
|
+
'css': css,
|
|
2680
|
+
'gap': max(0, round(min(gaps))) if gaps else 0,
|
|
2681
|
+
'items': children,
|
|
2682
|
+
})
|
|
2683
|
+
return out
|
|
2684
|
+
|
|
2685
|
+
|
|
2686
|
+
def structure_facts(archetypes, d, shapes):
|
|
2687
|
+
"""每个页型的**结构事实**:栅格、垂直间距序列、容器样式配方、样张里的实际字数。
|
|
2688
|
+
|
|
2689
|
+
这些是判「该用绝对坐标还是流式」的依据,脚本只测不判:
|
|
2690
|
+
- 栅格拟合好不好,决定这页是不是一个规整的多列区带
|
|
2691
|
+
- 垂直间距序列里的突变点,就是区带的边界(网格内部 24、区带之间 110)
|
|
2692
|
+
- 样张字数说明这个框是按几行内容设计的——框高本身看不出这件事
|
|
2693
|
+
"""
|
|
2694
|
+
q = _load_query()
|
|
2695
|
+
by_part = defaultdict(list)
|
|
2696
|
+
for s in shapes:
|
|
2697
|
+
by_part[s.get('part')].append(s)
|
|
2698
|
+
|
|
2699
|
+
# 容器样式配方:跨全档聚类一次,记出现次数与跨页数,供判断「哪些是共性风格」
|
|
2700
|
+
groups = {}
|
|
2701
|
+
for s in shapes:
|
|
2702
|
+
fill, line, fx = s.get('fill'), s.get('line'), s.get('effects')
|
|
2703
|
+
if not fill and not line and not fx:
|
|
2704
|
+
continue
|
|
2705
|
+
if isinstance(fill, dict) and fill.get('type') == 'image':
|
|
2706
|
+
continue
|
|
2707
|
+
k = q._sig(fill, line, fx)
|
|
2708
|
+
if k[0] == 'none' and k[1] == 'none' and not k[2]:
|
|
2709
|
+
continue
|
|
2710
|
+
g = groups.setdefault(k, {'n': 0, 'parts': set(), 'radii': [],
|
|
2711
|
+
'fill': fill, 'line': line, 'fx': fx, 'shapes': set()})
|
|
2712
|
+
g['n'] += 1
|
|
2713
|
+
g['parts'].add(s.get('part'))
|
|
2714
|
+
g['radii'].append(s.get('radius_px') or 0)
|
|
2715
|
+
g['shapes'].add(id(s))
|
|
2716
|
+
ranked = sorted(groups.values(), key=lambda g: -g['n'])
|
|
2717
|
+
recipe_id = {}
|
|
2718
|
+
recipes = []
|
|
2719
|
+
for i, g in enumerate(ranked, 1):
|
|
2720
|
+
rid = 'r%d' % i
|
|
2721
|
+
css = [re.sub(r'\s*\n\s*', ' ', c.split('\x00')[0]).strip()
|
|
2722
|
+
for c in q._recipe_css(g['fill'], g['line'], g['radii'], g['fx']) if c]
|
|
2723
|
+
recipes.append({'id': rid, 'n': g['n'], 'pages': len(g['parts']),
|
|
2724
|
+
'css': '; '.join(css)})
|
|
2725
|
+
for sid in g['shapes']:
|
|
2726
|
+
recipe_id[sid] = rid
|
|
2727
|
+
|
|
2728
|
+
grids = (d.get('spacing_candidates') or {}).get('grids') or []
|
|
2729
|
+
grid_by_part = defaultdict(list)
|
|
2730
|
+
for gd in grids:
|
|
2731
|
+
grid_by_part[gd.get('part')].append(gd)
|
|
2732
|
+
|
|
2733
|
+
out = {}
|
|
2734
|
+
for a in archetypes:
|
|
2735
|
+
part = None
|
|
2736
|
+
if a.get('source', '').startswith('layout:'):
|
|
2737
|
+
part = 'ppt/slideLayouts/' + a['source'].split(':', 1)[1]
|
|
2738
|
+
elif a.get('rep'):
|
|
2739
|
+
part = 'ppt/slides/slide%d.xml' % a['rep']
|
|
2740
|
+
boxes = [s['box'] for s in a['slots']] + [x['box'] for x in (a.get('decor') or [])]
|
|
2741
|
+
boxes.sort(key=lambda b: b[1])
|
|
2742
|
+
gaps = [boxes[i + 1][1] - (boxes[i][1] + boxes[i][3]) for i in range(len(boxes) - 1)]
|
|
2743
|
+
chars = [(s['box'], len(s.get('txt') or '')) for s in a['slots'] if s.get('txt')]
|
|
2744
|
+
used = []
|
|
2745
|
+
for s in by_part.get(part, []):
|
|
2746
|
+
rid = recipe_id.get(id(s))
|
|
2747
|
+
if rid and rid not in used:
|
|
2748
|
+
used.append(rid)
|
|
2749
|
+
out[a['name']] = {'grids': grid_by_part.get(part) or [], 'gaps': gaps,
|
|
2750
|
+
'chars': chars, 'recipes': used}
|
|
2751
|
+
return out, recipes
|
|
2752
|
+
|
|
2753
|
+
|
|
2754
|
+
LAYOUT_CONTROL_KEYS = {
|
|
2755
|
+
'names', 'roles', 'text_roles', 'layout_modes', 'bg_rules',
|
|
2756
|
+
}
|
|
2757
|
+
|
|
2758
|
+
|
|
2759
|
+
def emit_layout_controls(layout_lines, ldir, text_role_candidates, flow_archetypes):
|
|
2760
|
+
"""从兼容用的 layouts.yaml 分出模型只需编辑的判断区。
|
|
2761
|
+
|
|
2762
|
+
旧判断单把控制项和每个 slot 的坐标正文混在一起。模型为补一个角色读取整份文件,
|
|
2763
|
+
在版式很多的模板上会把时间耗在无须判断的数值上。仍保留旧文件给既有调用方;
|
|
2764
|
+
新文件只承载最终可覆盖它的五个顶层判断区。
|
|
2765
|
+
"""
|
|
2766
|
+
blocks, current = {}, None
|
|
2767
|
+
for line in layout_lines:
|
|
2768
|
+
match = re.match(r'^([A-Za-z_][\w-]*):', line)
|
|
2769
|
+
if match:
|
|
2770
|
+
current = match.group(1)
|
|
2771
|
+
if current in LAYOUT_CONTROL_KEYS:
|
|
2772
|
+
blocks[current] = [line]
|
|
2773
|
+
continue
|
|
2774
|
+
if current in blocks:
|
|
2775
|
+
blocks[current].append(line)
|
|
2776
|
+
|
|
2777
|
+
controls = [
|
|
2778
|
+
'# 版式判断控制区 —— 只读并编辑本文件;不要打开或修改 layouts.yaml。',
|
|
2779
|
+
'# package.py 会用本文件覆盖 layouts.yaml 的同名判断区,后者仅保留坐标事实与兼容输入。',
|
|
2780
|
+
'# 可编辑顶层键仅为 names / roles / text_roles / layout_modes / bg_rules。',
|
|
2781
|
+
]
|
|
2782
|
+
for key in ('names', 'roles'):
|
|
2783
|
+
if key in blocks:
|
|
2784
|
+
controls.extend([''] + blocks[key])
|
|
2785
|
+
if text_role_candidates:
|
|
2786
|
+
controls += [
|
|
2787
|
+
'',
|
|
2788
|
+
'text_roles:',
|
|
2789
|
+
'# 默认文字槽都是 body;仅把确认属于 title|subtitle|header|footer 的例外填为',
|
|
2790
|
+
'# <id>: title(不要给普通正文补 body)。候选对应的原始槽位在下列注释中。',
|
|
2791
|
+
]
|
|
2792
|
+
for role_id, slot in text_role_candidates:
|
|
2793
|
+
controls.append('# %s:%s' % (
|
|
2794
|
+
role_id, (slot.get('txt') or '(无样本文字)')[:60]))
|
|
2795
|
+
if flow_archetypes:
|
|
2796
|
+
controls += [
|
|
2797
|
+
'',
|
|
2798
|
+
'layout_modes:',
|
|
2799
|
+
'# 默认 slots。只有样张明确需要内容随高度重排时,取消注释并填 `<页型>: flow`。',
|
|
2800
|
+
]
|
|
2801
|
+
controls.extend('# %s: flow' % archetype['name'] for archetype in flow_archetypes)
|
|
2802
|
+
if 'bg_rules' in blocks:
|
|
2803
|
+
controls += [
|
|
2804
|
+
'',
|
|
2805
|
+
'# 只编辑本草案已列出的真实图片背景;它们都已被 layouts 中的 background: 引用。',
|
|
2806
|
+
'# 纯色、渐变、外框、几何装饰和透明叠层不新建 bg_rules;没有本段就保持没有。',
|
|
2807
|
+
] + blocks['bg_rules']
|
|
2808
|
+
write(os.path.join(ldir, 'layout-controls.yaml'), '\n'.join(controls) + '\n')
|
|
2809
|
+
|
|
2810
|
+
|
|
2811
|
+
def emit_layouts(archetypes, ldir, busy_hints=None, facts=None, recipes=None):
|
|
2812
|
+
sampled_archetypes = {
|
|
2813
|
+
id(archetype) for archetype in archetypes
|
|
2814
|
+
if (archetype.get('rep') is not None
|
|
2815
|
+
or archetype.get('pages')
|
|
2816
|
+
or archetype.get('_sample_pages'))
|
|
2817
|
+
}
|
|
2818
|
+
prefilled = sum(1 for a in archetypes if a.get('zh'))
|
|
2819
|
+
L = ['# 判断单草案 —— package.py 读它产出 layouts.md,deck 的版式坐标从 layouts.md 读。',
|
|
2820
|
+
'# 只改 names / roles / text_roles / layout_modes / bg_rules 五段(都是扁平键值,'
|
|
2821
|
+
'改完 package.py 自动并回各页型)。',
|
|
2822
|
+
'# 下面 layouts 段是普查数值,一个字都不要动——改它容易连带删掉 slots/confidence。']
|
|
2823
|
+
if prefilled:
|
|
2824
|
+
L.append('# names 已按模板自带的版式名填好 %d 条,读一遍确认表意即可,通常不用改。' % prefilled)
|
|
2825
|
+
if recipes:
|
|
2826
|
+
L.append('# 容器样式配方(按出现次数排;跨页数多 = 共性风格,只在一处出现的多半不是):')
|
|
2827
|
+
for r in recipes[:8]:
|
|
2828
|
+
L.append('# %s 出现 %d 次 / 跨 %d 处 %s' % (r['id'], r['n'], r['pages'], r['css']))
|
|
2829
|
+
L.append('names:')
|
|
2830
|
+
for a in archetypes:
|
|
2831
|
+
if a.get('zh'):
|
|
2832
|
+
# 模板自己给版式起了名(form=3),直接用——比看图起名准,也省掉一轮判断
|
|
2833
|
+
L.append(' %s: %s' % (a['name'], q(a['zh'])))
|
|
2834
|
+
else:
|
|
2835
|
+
L.append(' %s: TODO中文名(代表页 %s,共 %d 页)'
|
|
2836
|
+
% (a['name'], a['rep'], len(a['pages'])))
|
|
2837
|
+
# 角色(封面 / 章节页 / 内容页……)是看图才能下的结论,脚本不猜。模板自己按页型
|
|
2838
|
+
# 命名时用它的标注,否则连同客观事实一起摆出来,由看得到重建图的你来定。
|
|
2839
|
+
need_role = [a for a in archetypes if not a.get('role')]
|
|
2840
|
+
if need_role:
|
|
2841
|
+
L.append('roles: # 取值 cover|section|content|quote|closing|blank|custom')
|
|
2842
|
+
for a in need_role:
|
|
2843
|
+
szs = sorted({round(s['sz']) for s in a['slots'] if s.get('sz')}, reverse=True)
|
|
2844
|
+
L.append(' %s: TODO角色 # 代表页 %s,共 %d 页;文字块 %d 个,字号 %s;'
|
|
2845
|
+
'图片 %d 张%s%s'
|
|
2846
|
+
% (a['name'], a['rep'], len(a['pages']),
|
|
2847
|
+
len([s for s in a['slots'] if not s.get('asset')]),
|
|
2848
|
+
'/'.join(str(x) for x in szs[:5]) or '未声明',
|
|
2849
|
+
a.get('pic_n') or 0, ';有满屏底图' if a.get('bg_raw') else '',
|
|
2850
|
+
';末页候选,结合样张判断 closing 或实际角色'
|
|
2851
|
+
if a.get('_last_page_candidate') else ''))
|
|
2852
|
+
# 普通正文先保持 body:它是安全且可消费的默认值。标题/页眉/页脚的少量例外依然
|
|
2853
|
+
# 要由模型看样张后写入 text_roles;把每一个正文槽都做成 TODO 会迫使模型逐行复述
|
|
2854
|
+
# 近百个显然的 body,挤占真正的视觉判断时间。
|
|
2855
|
+
text_role_ids = {}
|
|
698
2856
|
for a in archetypes:
|
|
699
|
-
|
|
2857
|
+
index = 0
|
|
2858
|
+
for slot in a.get('slots') or []:
|
|
2859
|
+
if not slot.get('_needs_role'):
|
|
2860
|
+
continue
|
|
2861
|
+
index += 1
|
|
2862
|
+
text_role_ids[id(slot)] = '%s-text-%d' % (a['name'], index)
|
|
2863
|
+
if text_role_ids:
|
|
2864
|
+
L.append('# 文字槽默认均为 body。看样张后,只把确实属于 title|subtitle|header|footer 的'
|
|
2865
|
+
'例外追加到 text_roles:;不要为普通正文逐条补 body。')
|
|
2866
|
+
flow_archetypes = [a for a in archetypes if a.get('flow')]
|
|
2867
|
+
if flow_archetypes:
|
|
2868
|
+
L.append('# 同时有 flow 与 slots 时默认保留 slots,保证固定构图可消费。'
|
|
2869
|
+
'只有样张明确需要内容随高度重排时,才在 layout_modes: 中写 <页型>: flow。')
|
|
2870
|
+
# 禁放区是**背景图**的属性,不是页型的属性——按背景资产分组,页型再多也不涨
|
|
2871
|
+
bgs = []
|
|
2872
|
+
for a in archetypes:
|
|
2873
|
+
if a['bg'] and a['bg'] not in bgs:
|
|
2874
|
+
bgs.append(a['bg'])
|
|
2875
|
+
if bgs:
|
|
2876
|
+
L.append('bg_rules:')
|
|
2877
|
+
for bg in bgs:
|
|
2878
|
+
users = [a['name'] for a in archetypes if a['bg'] == bg]
|
|
2879
|
+
L.append(' %s: # 用它的页型:%s' % (bg, ', '.join(users)))
|
|
2880
|
+
hint = (busy_hints or {}).get(bg)
|
|
2881
|
+
if hint:
|
|
2882
|
+
L.append(' # 图像局部对比度:中位 %s、九分位 %s;%s'
|
|
2883
|
+
% (hint['median'], hint['p90'],
|
|
2884
|
+
('更花的一片在 %s' % hint['busy']) if hint.get('busy')
|
|
2885
|
+
else hint.get('why', '')))
|
|
2886
|
+
# text_safe 不是判断题:模板自己已经把文字放在哪儿写死了。取用这张背景的
|
|
2887
|
+
# 所有页型的槽与装饰件的外接并集即可——让人看图猜只会猜得更松,把模板从不
|
|
2888
|
+
# 放字的区域也划进安全区,这个字段就白设了。
|
|
2889
|
+
boxes = [s['box'] for a in archetypes if a['bg'] == bg for s in a['slots']] + \
|
|
2890
|
+
[dcr['box'] for a in archetypes if a['bg'] == bg for dcr in (a.get('decor') or [])]
|
|
2891
|
+
if boxes:
|
|
2892
|
+
x0 = min(b[0] for b in boxes)
|
|
2893
|
+
y0 = min(b[1] for b in boxes)
|
|
2894
|
+
x1 = max(b[0] + b[2] for b in boxes)
|
|
2895
|
+
y1 = max(b[1] + b[3] for b in boxes)
|
|
2896
|
+
L.append(' text_safe: [%d, %d, %d, %d] # 由该背景各页型的槽位并集算出'
|
|
2897
|
+
% (x0, y0, x1 - x0, y1 - y0))
|
|
2898
|
+
elif any(id(archetype) in sampled_archetypes
|
|
2899
|
+
for archetype in archetypes if archetype['bg'] == bg):
|
|
2900
|
+
L.append(' text_safe: TODO安全文字区[x,y,w,h](该背景下没有任何槽位可依据)')
|
|
2901
|
+
else:
|
|
2902
|
+
L.append(' text_safe: [0, 0, 0, 0] # 未见对应样张,没有可依据的文字区')
|
|
2903
|
+
if any(id(archetype) in sampled_archetypes
|
|
2904
|
+
for archetype in archetypes if archetype['bg'] == bg):
|
|
2905
|
+
L.append(' avoid: TODO禁放区列表;无禁放区写 [],有则写 [{box: [x,y,w,h], reason: "..."}]')
|
|
2906
|
+
L.append(' pairing_rule: "TODO这张背景上标题/正文/图表要避让哪些区域"')
|
|
2907
|
+
else:
|
|
2908
|
+
L.append(' avoid: [] # 未见对应样张,不额外推断禁放区')
|
|
2909
|
+
L.append(' pairing_rule: "未见对应样张;沿用该页型已有槽位"')
|
|
700
2910
|
L.append('layouts:')
|
|
701
2911
|
for a in archetypes:
|
|
2912
|
+
fx = (facts or {}).get(a['name']) or {}
|
|
2913
|
+
if fx:
|
|
2914
|
+
# 结构事实:判「这页该用绝对坐标还是流式」的依据。脚本只测不判。
|
|
2915
|
+
for gd in (fx.get('grids') or [])[:2]:
|
|
2916
|
+
c, r = gd.get('cols') or {}, gd.get('rows') or {}
|
|
2917
|
+
L.append(' # 栅格:%s 列%s%s' % (
|
|
2918
|
+
c.get('n'), ' @%gpx 步距方差 %.2f' % (c.get('pitch') or 0, c.get('sd') or 0)
|
|
2919
|
+
if c.get('regular') else '(列不规整)',
|
|
2920
|
+
',行 %s' % (('%d @%gpx' % (r.get('n') or 0, r.get('pitch') or 0))
|
|
2921
|
+
if r.get('regular') else '不规整')))
|
|
2922
|
+
if fx.get('gaps'):
|
|
2923
|
+
L.append(' # 垂直间距:%s(突变处即区带边界)'
|
|
2924
|
+
% '、'.join(str(int(g)) for g in fx['gaps'][:10]))
|
|
2925
|
+
if fx.get('chars'):
|
|
2926
|
+
L.append(' # 样张字数:%s'
|
|
2927
|
+
% '、'.join('%s=%d字' % (b, n) for b, n in fx['chars'][:6]))
|
|
2928
|
+
if fx.get('recipes'):
|
|
2929
|
+
L.append(' # 命中配方:%s' % '、'.join(fx['recipes'][:4]))
|
|
2930
|
+
# 槽与槽在坐标上重叠:PPT 里占位符互相压是常态(文字 valign 居中、样张只有一行,
|
|
2931
|
+
# 看不出来),照抄坐标做成 HTML 后内容一变长就撞。实测封面 title 框比 subtitle
|
|
2932
|
+
# 的顶还低 41px,两行标题直接压在副标题上。这里只报事实,怎么让开由你定。
|
|
2933
|
+
ov = slot_overlaps(a.get('slots') or [])
|
|
2934
|
+
if ov:
|
|
2935
|
+
L.append(' # 槽位重叠:%s(模板里靠文字居中不显形,内容变长会撞)'
|
|
2936
|
+
% '、'.join(ov[:3]))
|
|
702
2937
|
L.append(' %s:' % a['name'])
|
|
703
|
-
|
|
2938
|
+
if a.get('role'):
|
|
2939
|
+
L.append(' role: %s' % a['role'])
|
|
2940
|
+
if a.get('theme'):
|
|
2941
|
+
L.append(' themes: [%s]' % a['theme'])
|
|
704
2942
|
if a['bg']:
|
|
705
2943
|
L.append(' background: %s' % a['bg'])
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
L.append('
|
|
2944
|
+
fl = a.get('flow')
|
|
2945
|
+
if fl:
|
|
2946
|
+
L.append(' flow:')
|
|
2947
|
+
L.append(' top: %d' % fl['top'])
|
|
2948
|
+
L.append(' margin: [%d, %d]' % tuple(fl['margin']))
|
|
2949
|
+
L.append(' gap: %d' % fl['gap'])
|
|
2950
|
+
L.append(' regions:')
|
|
2951
|
+
for r in fl['regions']:
|
|
2952
|
+
if r['kind'] == 'grid':
|
|
2953
|
+
L.append(' - kind: grid')
|
|
2954
|
+
L.append(' cols: %d' % r['cols'])
|
|
2955
|
+
L.append(' gap: [%d, %d]' % tuple(r['gap']))
|
|
2956
|
+
if r.get('margin'):
|
|
2957
|
+
L.append(' margin: [%d, %d] # 本区带自己的左右边距,'
|
|
2958
|
+
'和整页 margin 不同(居中卡片组不跟标题的左边距)'
|
|
2959
|
+
% tuple(r['margin']))
|
|
2960
|
+
elif r['kind'] == 'free':
|
|
2961
|
+
L.append(' - kind: free # 推不出规整结构,按 slots 的坐标摆')
|
|
2962
|
+
else:
|
|
2963
|
+
L.append(' - kind: stack')
|
|
2964
|
+
L.append(' gap: %d' % r['gap'])
|
|
2965
|
+
L.append(' items:')
|
|
2966
|
+
for s in r['items']:
|
|
2967
|
+
if s.get('type') == 'group':
|
|
2968
|
+
L.append(' - role: group')
|
|
2969
|
+
L.append(' gap: %d' % s['gap'])
|
|
2970
|
+
if s.get('css'):
|
|
2971
|
+
L.append(' css: "%s"'
|
|
2972
|
+
% str(s['css']).replace('"', "'"))
|
|
2973
|
+
L.append(' items:')
|
|
2974
|
+
for child in s['items']:
|
|
2975
|
+
role_id = text_role_ids.get(id(child))
|
|
2976
|
+
if role_id:
|
|
2977
|
+
L.append(' # text-role: %s' % role_id)
|
|
2978
|
+
if child.get('type') == 'decor':
|
|
2979
|
+
L.append(' - {role: container, css: "%s"}'
|
|
2980
|
+
% str(child.get('css') or '').replace('"', "'"))
|
|
2981
|
+
continue
|
|
2982
|
+
extra = ''
|
|
2983
|
+
if child.get('css') is not None:
|
|
2984
|
+
extra += ', css: "%s"' % str(child['css']).replace('"', "'")
|
|
2985
|
+
if child.get('asset'):
|
|
2986
|
+
extra += ', asset: %s' % child['asset']
|
|
2987
|
+
if child.get('source_media'):
|
|
2988
|
+
extra += ', source_media: %s' % child['source_media']
|
|
2989
|
+
extra += ', source_box: %s' % child['box']
|
|
2990
|
+
L.append(' - {role: %s, type: %s%s}'
|
|
2991
|
+
% (child['role'], child['type'], extra))
|
|
2992
|
+
continue
|
|
2993
|
+
role_id = text_role_ids.get(id(s))
|
|
2994
|
+
if role_id:
|
|
2995
|
+
L.append(' # text-role: %s' % role_id)
|
|
2996
|
+
# free 区带按坐标摆,而 slots 会被删掉,所以坐标必须写在这里
|
|
2997
|
+
bx = ', box: %s' % s['box'] if r['kind'] == 'free' else ''
|
|
2998
|
+
if s.get('type') == 'decor':
|
|
2999
|
+
L.append(' - {role: container%s, css: "%s"}'
|
|
3000
|
+
% (bx, (s.get('css') or '').replace('"', "'")))
|
|
3001
|
+
continue
|
|
3002
|
+
extra = bx
|
|
3003
|
+
if s.get('css') is not None:
|
|
3004
|
+
# CSS 串一律加引号:里面的逗号/冒号在 flow map 里是分隔符
|
|
3005
|
+
extra += ', css: "%s"' % str(s['css']).replace('"', "'")
|
|
3006
|
+
if s.get('asset'):
|
|
3007
|
+
extra += ', asset: %s' % s['asset']
|
|
3008
|
+
if s.get('source_media'):
|
|
3009
|
+
extra += ', source_media: %s' % s['source_media']
|
|
3010
|
+
if r['kind'] != 'free':
|
|
3011
|
+
extra += ', source_box: %s' % s['box']
|
|
3012
|
+
L.append(' - {role: %s, type: %s%s}' % (s['role'], s['type'], extra))
|
|
709
3013
|
L.append(' slots:')
|
|
710
3014
|
for s in a['slots']:
|
|
711
|
-
|
|
3015
|
+
role_id = text_role_ids.get(id(s))
|
|
3016
|
+
if role_id:
|
|
3017
|
+
L.append(' # text-role: %s' % role_id)
|
|
3018
|
+
extra = ''
|
|
3019
|
+
if s.get('asset'):
|
|
3020
|
+
extra += ', asset: %s' % s['asset']
|
|
3021
|
+
if s.get('source_media'):
|
|
3022
|
+
extra += ', source_media: %s' % s['source_media']
|
|
3023
|
+
if s.get('css') is not None:
|
|
3024
|
+
extra += ', css: "%s"' % str(s['css']).replace('"', "'")
|
|
3025
|
+
L.append(' - {role: %s, box: %s, type: %s%s}'
|
|
3026
|
+
% (s['role'], s['box'], s['type'], extra))
|
|
3027
|
+
if a.get('decor'):
|
|
3028
|
+
L.append(' decor:')
|
|
3029
|
+
for dcr in a['decor']:
|
|
3030
|
+
L.append(' - {box: %s, geom: %s, css: "%s"}'
|
|
3031
|
+
% (dcr['box'], dcr['geom'], dcr['css'].replace('"', "'")))
|
|
712
3032
|
L.append(' confidence: %s' % a.get('confidence', 'medium'))
|
|
713
3033
|
write(os.path.join(ldir, 'layouts.yaml'), '\n'.join(L) + '\n')
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
3034
|
+
emit_layout_controls(L, ldir, [
|
|
3035
|
+
(text_role_ids[id(slot)], slot)
|
|
3036
|
+
for archetype in archetypes
|
|
3037
|
+
for slot in archetype.get('slots') or []
|
|
3038
|
+
if id(archetype) in sampled_archetypes and id(slot) in text_role_ids
|
|
3039
|
+
], [
|
|
3040
|
+
archetype for archetype in flow_archetypes
|
|
3041
|
+
if id(archetype) in sampled_archetypes
|
|
3042
|
+
])
|
|
3043
|
+
|
|
3044
|
+
|
|
3045
|
+
def emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, ldir,
|
|
3046
|
+
has_asset_candidates=False):
|
|
3047
|
+
"""design.md 正文。
|
|
3048
|
+
|
|
3049
|
+
每条规则只出现一次——同一条散在 Fast Path / Usage / Background Safety /
|
|
3050
|
+
Hard Rules 各写一遍时措辞必然漂移,消费端无法判断哪份权威。
|
|
3051
|
+
坐标、字号、色值、资产位置的权威都在 layouts.md;本文件只给色板、字体栈与纪律。
|
|
3052
|
+
"""
|
|
717
3053
|
canvas = d['canvas']['px']
|
|
718
3054
|
cover = next((a for a in assets if a['id'] == 'bg-cover'), None)
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
L
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
'
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
'
|
|
3055
|
+
imp, webs = import_line(fonts)
|
|
3056
|
+
sidecar = '`layouts.md`'
|
|
3057
|
+
|
|
3058
|
+
L = ['## Overview', '',
|
|
3059
|
+
'TODO: 两三句话讲清这套模板的性格与适用场景——看过联系表和页面重建图之后再写。', '']
|
|
3060
|
+
L.append(('模板自带 %d 种版式,页型、坐标和 CSS 样式都直读自版式层。'
|
|
3061
|
+
% len(archetypes)) if (d.get('form_hint') or {}).get('form') == 3 else
|
|
3062
|
+
('%d 页样张归纳出 %d 种页型。' % (d['counts']['slides'], len(archetypes))))
|
|
3063
|
+
L += ['', '## Usage', '',
|
|
3064
|
+
'**生成前必须完整阅读本 `design.md` 和 %s,确认全部页型后再开始搭页。**'
|
|
3065
|
+
'不能只看摘要、前几个页型或 `## Layouts` 清单;后续页型同样可能定义背景、'
|
|
3066
|
+
'安全区、资产和固定元素。' % sidecar,
|
|
3067
|
+
'',
|
|
3068
|
+
'搭一页 PPT 六步,中间四步的数据都在 %s:' % sidecar, '']
|
|
3069
|
+
L += ['1. **定画布** —— 舞台按 `layouts.md` 的 `canvas` 设成 %d×%d,'
|
|
3070
|
+
'别套用默认尺寸:源模板的长宽比不一定是 16:9,套错了整页坐标全偏。'
|
|
3071
|
+
'舞台尺寸改不了时,整体等比缩放 `min(舞台宽/%d, 舞台高/%d)` 后居中留白——'
|
|
3072
|
+
'逐轴拉伸会把圆压成椭圆、把字挤扁。' % (canvas[0], canvas[1], canvas[0], canvas[1]),
|
|
3073
|
+
'2. **挑页型** —— 在 %s 里按用途选一个 archetype(清单见下面 Layouts 段)。'
|
|
3074
|
+
'页数多于页型时,挑最接近的一个原样套用它的 slot:用不到的槽删掉,'
|
|
3075
|
+
'内容比槽多就按同类槽的间距等距加,**坐标一律沿用该页型给的那套,不要自己另起网格**。'
|
|
3076
|
+
'每个生成页面的 `<section>` 都写 `data-pptx-layout="<页型名>"`,'
|
|
3077
|
+
'多主题包默认只选兼容 `default-theme` 的页型;确需切换时,该页同时写 '
|
|
3078
|
+
'`data-pptx-theme="<主题名>"`,且主题必须属于该页型的 `themes`。'
|
|
3079
|
+
'交付前据此核验该页型绑定的背景与图片资产均已使用,且没有跨页型误用。' % sidecar,
|
|
3080
|
+
'3. **按页型给的形态落元素** —— 页型给 `flow` 就用流式,给 `slots` 就用绝对,'
|
|
3081
|
+
'两者只会出现一个。'
|
|
3082
|
+
'**flow**:整块用一个纵向 flex 容器,`top` 是它的起始 y,`margin` 是整块的左右边距,'
|
|
3083
|
+
'`gap` 是区带之间的间距;`regions` 从上往下依次排,**每个区带的高度由它自己的'
|
|
3084
|
+
'内容决定,不要写死高度**——上面的区带内容变多时,下面的自然被推下去,这正是'
|
|
3085
|
+
'这套表达要解决的事。区带内部:`kind: grid` 用 `grid-template-columns: repeat(cols, 1fr)` '
|
|
3086
|
+
'配 `gap: [行间距, 列间距]`;`kind: stack` 用纵向 flex 配 `gap`;`kind: free` '
|
|
3087
|
+
'按 item 自带的 `box` 绝对定位。区带自带 `margin: [左, 右]` 时用它的、'
|
|
3088
|
+
'覆盖整块的 `margin`(模板里居中的卡片组和贴左的标题横向范围本就不同);'
|
|
3089
|
+
'没带就用整块的 `margin`。`grid` 在自己这份左右边距里再 `repeat(cols, 1fr)`。'
|
|
3090
|
+
'`grid` 里的 `role: group` 是一张卡片:'
|
|
3091
|
+
'group 的 `css` 用于外层容器,内部 `items` 按顺序纵向排布并使用 group 的 `gap`。'
|
|
3092
|
+
'每个 `role: container` 的项是容器,把它的 `css` 逐项原样写进 style,内容放进去;'
|
|
3093
|
+
'其中没有 `border-radius` 就按 `0`,不得自行补圆角。',
|
|
3094
|
+
'4. **按 slot 落元素(页型给的是 slots 时)** —— 每个 slot 渲染成一个绝对定位元素:`box` 是 '
|
|
3095
|
+
'`[x, y, w, h]`(%dx%d 画布上的绝对像素),机械展开成 `left/top/width/height`;'
|
|
3096
|
+
'slot 的 `css` 是模板排版属性已转译好的声明串,原样写进 style,不要另选字号、'
|
|
3097
|
+
'内边距、颜色或对齐。'
|
|
3098
|
+
'带 `asset` 的 slot 是固定图片实例:元素写 `data-pptx-asset="<asset id>"`,'
|
|
3099
|
+
'引用复制后的原资产,并把 `box` 直接写成 inline '
|
|
3100
|
+
'`position:absolute;left:<x>px;top:<y>px;width:<w>px;height:<h>px`。'
|
|
3101
|
+
'元素必须可见,不得省略或换图,也不得隐藏或只在 CSS 里伪装引用;'
|
|
3102
|
+
'这个页型没有 `asset` 槽,这一页就不出现该资产。'
|
|
3103
|
+
'页型的 `background` 是图片资产时遵循同一实例契约,`box` 使用全画布 '
|
|
3104
|
+
'`[0, 0, %d, %d]`。' % (canvas[0], canvas[1], canvas[0], canvas[1]),
|
|
3105
|
+
'5. **铺装饰几何** —— 页型的 `decor` 是这一页的图形骨架(图标托底的圆、'
|
|
3106
|
+
'卡片、分隔线):每条渲染成一个绝对定位空元素,`box` 给位置,`css` 逐项原样写进 '
|
|
3107
|
+
'style;没有 `border-radius` 就按 `0`。只有 `geom: ellipse` 另加 '
|
|
3108
|
+
'`border-radius: 50%`。它们压在背景之上、slot 之下,'
|
|
3109
|
+
'落在 slot 上的图标正是靠它们托住。',
|
|
3110
|
+
'6. **落实全局设计** —— `design.md` frontmatter 的 `colors`、`typography`、'
|
|
3111
|
+
'`spacing`、`rounded`、`components` 是全局 token;用 CSS variables、类名或内联'
|
|
3112
|
+
'样式承载。局部 slot / decor 的 `css` 优先,不能再解释成另一套视觉系统。'
|
|
3113
|
+
'字体使用 Typography 的完整栈与降级,不在运行时安装字体或依赖。',
|
|
3114
|
+
'7. **保持标题结构** —— 有合适页型可参考时,沿用该页型已有的标题层级与局部 '
|
|
3115
|
+
'`css`;只渲染该页型已有的文字槽,背景中已经可见的固定标题不再创建文本,'
|
|
3116
|
+
'页型没有 `subtitle` 槽就不新增副标题。没有合适参考时,按本包整体视觉组织标题。']
|
|
3117
|
+
if assets or has_asset_candidates:
|
|
3118
|
+
L += ['', '资产文件(背景由页型的 `background` 字段指定,'
|
|
3119
|
+
'图片资产的位置由该页型 `slots` 里带 `asset` 的槽给出):', '',
|
|
3120
|
+
'{{ASSET_TABLE}}', '',
|
|
3121
|
+
'将包内 `assets/` 复制到项目内相对目录,再引用复制后的路径;最终 HTML 不引用'
|
|
3122
|
+
'抽取工作目录或本机绝对路径。附件只提供 `assetRoot` / `assetPaths` 时,把'
|
|
3123
|
+
'`assetRoot` 当作不透明前缀,只拼接清单中声明的相对路径。']
|
|
3124
|
+
L += ['', '文字与容器的外接矩形落在该页型 `background` 对应的 `text_safe` 内,'
|
|
3125
|
+
'避开 `avoid` 列出的区域(两者都在 %s 的 `backgrounds` 段)。内容装不下时换页型或拆页。'
|
|
3126
|
+
% sidecar, '',
|
|
780
3127
|
'## Colors', '', '| token | 值 | 用途 |', '|---|---|---|']
|
|
781
|
-
USE = {'surface': '页面与卡片主底色', 'surface-alt': '次级底色,分区/强调区块的浅底',
|
|
782
|
-
'ink': '正文与标题文字色', 'ink-muted': '次级文字色,说明与标签',
|
|
783
|
-
'primary': '主强调色:图表主序列、关键数字、行动点',
|
|
784
|
-
'accent': '副强调色,多与 primary 组成渐变',
|
|
785
|
-
'accent-2': '渐变与图表的第二落点色', 'accent-3': '渐变收尾色,用量最少',
|
|
786
|
-
'neutral': '中性弱化色:分隔线、次要标签'}
|
|
787
3128
|
for name, r in tokens:
|
|
788
|
-
L.append('| `%s` | `%s` | %s |'
|
|
789
|
-
|
|
3129
|
+
L.append('| `%s` | `%s` | %s |'
|
|
3130
|
+
% (name, r['hex'], usage_phrase(cusage.get(r['hex'].upper()))))
|
|
790
3131
|
L += ['', '## Typography', '']
|
|
791
3132
|
for f in fonts[:2]:
|
|
792
3133
|
L.append('- **%s** —— 栈 `%s`%s' % (
|
|
793
3134
|
f['names'][0], font_css(f['stack']),
|
|
794
3135
|
',源为商业/内部字体无 web 分发源,按气质降级到 %s' % f['stack'][1]
|
|
795
3136
|
if len(f['stack']) > 1 else ''))
|
|
796
|
-
L += ['', '字号轴:' + '、'.join('%s %dpx' % (k, round(v['sz_px'])) for k, v in roles.items())
|
|
3137
|
+
L += ['', '字号轴:' + '、'.join('%s %dpx' % (k, round(v['sz_px'])) for k, v in roles.items())
|
|
3138
|
+
+ '。slot 自带 `css` 时以其中的 `font-size` 为准;没有 slot CSS 的新增层级,'
|
|
3139
|
+
'复用轴上最接近的一档。', '',
|
|
797
3140
|
'字体加载(**HARD REQUIREMENT:下面这行 @import 原样写入全局样式首行,禁止替换为 '
|
|
798
3141
|
'fonts.googleapis.com 或其他域**):', '', '```', imp, '```', '',
|
|
799
|
-
'镜像只保证 wght 400
|
|
800
|
-
'系统字体 PingFang SC / Microsoft YaHei
|
|
801
|
-
'## Layouts', '',
|
|
802
|
-
'
|
|
803
|
-
|
|
804
|
-
L += ['', '## Hard Rules', '']
|
|
3142
|
+
'镜像只保证 wght 400 一档,更粗的字重由浏览器合成,字重不能作为唯一区分手段;'
|
|
3143
|
+
'系统字体 PingFang SC / Microsoft YaHei 置于栈末保底,中文场景负字距清零。', '',
|
|
3144
|
+
'## Layouts', '', '页型清单如下,每个页型的 slots、background、'
|
|
3145
|
+
'禁放区都在 %s:' % sidecar, '', '{{LAYOUT_LIST}}',
|
|
3146
|
+
'', '## Hard Rules', '']
|
|
805
3147
|
if cover:
|
|
806
|
-
L.append('-
|
|
807
|
-
% (canvas[0], canvas[1]))
|
|
3148
|
+
L.append('- 封面页铺满 `bg-cover`,整幅覆盖 %dx%d 画布。' % (canvas[0], canvas[1]))
|
|
808
3149
|
if any(a['role'] == 'content' for a in assets):
|
|
809
|
-
L.append('-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
'
|
|
818
|
-
'
|
|
819
|
-
'
|
|
3150
|
+
L.append('- 内容页的背景由该页型的 `background` 字段指定,整幅铺满。')
|
|
3151
|
+
L.append('{{LOGO_RULES}}')
|
|
3152
|
+
L += ['- 坐标、字号、色值、资产位置以 %s 为准;本文件的 Colors / Typography 是可用值的清单。'
|
|
3153
|
+
% sidecar,
|
|
3154
|
+
'- 强调色族以 Colors 和 %s 的 slot CSS 为主;必要时可以使用 Colors 之外的颜色,'
|
|
3155
|
+
'但不能形成与模板主色竞争的第二强调色。' % sidecar,
|
|
3156
|
+
'- 新增颜色应与模板整体的色相、明度和饱和度关系协调。允许新增中性色、低彩度辅助色'
|
|
3157
|
+
'或局部语义色表达正负、风险、警告、状态、图表序列,但保持辅助层级;'
|
|
3158
|
+
'只要新色通过高饱和、高对比、大面积或跨页重复获得主视觉权重,'
|
|
3159
|
+
'或被用于标题、关键数字、图表主序列、卡片底色或渐变,就属于新的强调色,改用模板'
|
|
3160
|
+
'强调色族的深浅、透明度,或改用线型、纹理、标签区分。',
|
|
3161
|
+
'- 交付前逐页检查:色板、字体、版式、背景、资产和本段规则均来自本风格包;'
|
|
3162
|
+
'页面无资源加载失败、内容溢出或画幅裁切。',
|
|
3163
|
+
'- 本包里的数值就是普查结果,照用即可,无需重新统计颜色、字体或版式。',
|
|
3164
|
+
'- 风格包以文本形式(zip 摘要等)到手时,直接用摘要里 design.md / layouts.md 的文本。',
|
|
820
3165
|
'', '## Exceptions', '']
|
|
821
3166
|
if exceptions:
|
|
822
3167
|
L += ['- ' + e for e in exceptions]
|
|
@@ -827,32 +3172,82 @@ def emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, ldir):
|
|
|
827
3172
|
|
|
828
3173
|
|
|
829
3174
|
def emit_brief(d, ctx, ldir):
|
|
830
|
-
(tokens, rest, fonts, roles, assets, rejected, todos, archetypes, cands,
|
|
831
|
-
leftover, lsheet) = ctx
|
|
3175
|
+
(tokens, rest, fonts, roles, assets, rejected, todos, archetypes, cands, sheets,
|
|
3176
|
+
selected_vision_groups, omitted_vision_groups, leftover, lsheet) = ctx
|
|
832
3177
|
canvas = d['canvas']['px']
|
|
833
|
-
|
|
3178
|
+
def sample_pages(archetype):
|
|
3179
|
+
return archetype.get('pages') or archetype.get('_sample_pages') or []
|
|
3180
|
+
|
|
3181
|
+
sampled_archetypes = [
|
|
3182
|
+
archetype for archetype in archetypes
|
|
3183
|
+
if archetype.get('rep') is not None or sample_pages(archetype)
|
|
3184
|
+
]
|
|
3185
|
+
template_only_archetypes = [
|
|
3186
|
+
archetype for archetype in archetypes
|
|
3187
|
+
if archetype not in sampled_archetypes
|
|
3188
|
+
]
|
|
3189
|
+
L = ['# 抽取简报(第 1/3 步产物;改完草案跑 package.py 出包)', '',
|
|
834
3190
|
'源:`%s` 画布 %dx%d %d 页 / %d 版式 主题 %s form=%s'
|
|
835
3191
|
% (d['source']['filename'], canvas[0], canvas[1], d['counts']['slides'],
|
|
836
3192
|
d['counts']['layouts'], d['theme_topology']['themes'],
|
|
837
3193
|
d['form_hint']['form']), '',
|
|
838
3194
|
'## 待判断(草案里已标 TODO,逐条改掉)', '']
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
3195
|
+
# 待判断清单从草案实时扫 TODO 生成,不写死:写死的清单会和草案对不上——
|
|
3196
|
+
# 既漏掉后加的段(模型读到一半才发现还有活),又在草案已预填时还催人去填。
|
|
3197
|
+
HINT = {'manifest.yaml': '看两张图定气质',
|
|
3198
|
+
'layout-controls.yaml': '看 layout-sheet.png;只改这个控制区',
|
|
3199
|
+
'body.md': 'Colors 用途列草案已填好,觉得不对再改'}
|
|
3200
|
+
for fn in ('manifest.yaml', 'body.md', 'layout-controls.yaml', 'frontmatter.yaml'):
|
|
3201
|
+
path = os.path.join(ldir, fn)
|
|
3202
|
+
if not os.path.exists(path):
|
|
3203
|
+
continue
|
|
3204
|
+
keys = []
|
|
3205
|
+
for line in open(path, encoding='utf-8'):
|
|
3206
|
+
if 'TODO' not in line:
|
|
3207
|
+
continue
|
|
3208
|
+
m = re.match(r'\s*[-#]?\s*([\w-]+):', line)
|
|
3209
|
+
keys.append(m.group(1) if m else line.strip()[:24])
|
|
3210
|
+
if not keys:
|
|
3211
|
+
continue
|
|
3212
|
+
seen, uniq = set(), []
|
|
3213
|
+
for k in keys:
|
|
3214
|
+
if k not in seen:
|
|
3215
|
+
seen.add(k)
|
|
3216
|
+
uniq.append(k)
|
|
3217
|
+
hint = HINT.get(fn)
|
|
3218
|
+
L.append('- `%s` %d 处:%s%s'
|
|
3219
|
+
% (fn, len(keys), '、'.join(uniq[:6]) + ('…' if len(uniq) > 6 else ''),
|
|
3220
|
+
'(%s)' % hint if hint else ''))
|
|
3221
|
+
for t in todos:
|
|
844
3222
|
L.append('- ' + t)
|
|
845
|
-
L += ['', '##
|
|
846
|
-
'
|
|
847
|
-
|
|
848
|
-
|
|
3223
|
+
L += ['', '## 资产判断(按视觉组一次看完)', '',
|
|
3224
|
+
('视觉判断拼版:%s。每张都含候选独立卡与所在页截图;只读这些拼版,不逐张打开素材。'
|
|
3225
|
+
% '、'.join('`l-out/%s`' % os.path.basename(path) for path in sheets))
|
|
3226
|
+
if sheets else '(未生成视觉拼版;不要给图片候选定性,已按内容图保留位置并在 gaps 说明。)',
|
|
3227
|
+
'`l-out/asset-vision-groups.json` 记录每张候选的原图尺寸、所有页内位置和尺寸;'
|
|
3228
|
+
'透明/近白候选在拼版中同时给棋盘格和深灰底预览。',
|
|
3229
|
+
'按每个候选实例填 `asset_vision_groups.visual_kind`;同源图在不同页型/位置可不同。'
|
|
3230
|
+
'第三方 logo 墙属于 `content-image`,不是 deck 的 `logo`。',
|
|
3231
|
+
'',
|
|
3232
|
+
'| ID | 文件 | 原图 | 出现 | 页 | 所有位置 |', '|---|---|---|---|---|---|']
|
|
849
3233
|
decided = {a['src']['file']: a['id'] for a in assets}
|
|
850
3234
|
why = {c['file']: r for c, r in rejected}
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
3235
|
+
selected_candidates = {
|
|
3236
|
+
candidate['file']: candidate
|
|
3237
|
+
for group in selected_vision_groups
|
|
3238
|
+
for candidate in group['candidates']
|
|
3239
|
+
}
|
|
3240
|
+
for c in selected_candidates.values():
|
|
3241
|
+
L.append('| `%s` | `%s` | %sx%s | %d | %s | `%s` |' % (
|
|
3242
|
+
c['id'], c['file'], c['probe'].get('w') or '?', c['probe'].get('h') or '?',
|
|
3243
|
+
c['n'], ','.join(map(str, c['slides'])) or 'layout', _placement_text(c)))
|
|
3244
|
+
if omitted_vision_groups:
|
|
3245
|
+
omitted_pages = sorted({
|
|
3246
|
+
page for group in omitted_vision_groups for page in group['pages'] if page > 0
|
|
3247
|
+
})
|
|
3248
|
+
L.append('')
|
|
3249
|
+
L.append('未进视觉预算:%s;对应 slot 默认保留内容图片位置,不会自动升为风格资产。'
|
|
3250
|
+
% ('第%s页' % '、'.join(map(str, omitted_pages)) if omitted_pages else '版式候选'))
|
|
856
3251
|
L += ['', '## 颜色(草案 token 已写进 frontmatter.yaml)', '',
|
|
857
3252
|
'| token | hex | 出现 |', '|---|---|---|']
|
|
858
3253
|
for name, r in tokens:
|
|
@@ -868,24 +3263,55 @@ def emit_brief(d, ctx, ldir):
|
|
|
868
3263
|
L.append('')
|
|
869
3264
|
L.append('字号轴:' + '、'.join('%s=%dpx(n=%d)' % (k, round(v['sz_px']), v['n'])
|
|
870
3265
|
for k, v in roles.items()))
|
|
871
|
-
L += ['', '##
|
|
872
|
-
'`l-out/layout-sheet.png` 是各页型代表页的重建图——**看它给页型起名**,'
|
|
873
|
-
|
|
3266
|
+
L += ['', '## 版式聚类(判断项在 layout-controls.yaml,坐标事实在 layouts.yaml)', '',
|
|
3267
|
+
('`l-out/layout-sheet.png` 是各页型代表页的重建图——**看它给页型起名**,'
|
|
3268
|
+
'不用再逐页查 shapes。' if sampled_archetypes else
|
|
3269
|
+
'`l-out/layout-sheet.png` 是模板版式层的重建图;用它看整体视觉即可,'
|
|
3270
|
+
'没有对应样张的版式已按模板名称预填,不逐项改名或判角色。')
|
|
3271
|
+
if lsheet else '(未生成版式图,按下面的 slot 原文命名)', '',
|
|
874
3272
|
'| archetype | 页数 | 代表页 | 背景 | slot 数 |', '|---|---|---|---|---|']
|
|
875
|
-
for a in
|
|
3273
|
+
for a in sampled_archetypes:
|
|
3274
|
+
pages = sample_pages(a)
|
|
3275
|
+
representative = a.get('rep') or (pages[0] if pages else None)
|
|
876
3276
|
L.append('| `%s` | %d | %s | %s | %d |' % (
|
|
877
|
-
a['name'], len(
|
|
3277
|
+
a['name'], len(pages), representative, a['bg'] or '(无资产底图)', len(a['slots'])))
|
|
3278
|
+
if template_only_archetypes:
|
|
3279
|
+
L.append('')
|
|
3280
|
+
L.append('另有 %d 个模板声明版式没有对应样张:名称、角色和坐标已预填并会进入最终包;'
|
|
3281
|
+
'除非当前样张直接证明不对,不需要逐项判断。'
|
|
3282
|
+
% len(template_only_archetypes))
|
|
3283
|
+
sampled_pages = {
|
|
3284
|
+
page: archetype
|
|
3285
|
+
for archetype in sampled_archetypes
|
|
3286
|
+
for page in sample_pages(archetype)
|
|
3287
|
+
}
|
|
3288
|
+
first_page = 1
|
|
3289
|
+
last_page = d['counts']['slides']
|
|
3290
|
+
if first_page in sampled_pages:
|
|
3291
|
+
first_archetype = sampled_pages[first_page]
|
|
3292
|
+
L.append('')
|
|
3293
|
+
L.append('第 1 页实际使用页型:`%s`。若样张确为封面,只在 `roles.%s` 填 `cover`,'
|
|
3294
|
+
'不要按页型名称猜。'
|
|
3295
|
+
% (first_archetype['name'], first_archetype['name']))
|
|
3296
|
+
if last_page != first_page and last_page in sampled_pages:
|
|
3297
|
+
last_archetype = sampled_pages[last_page]
|
|
3298
|
+
L.append('第 %d 页实际使用页型:`%s`。若样张确为封底,只在 `roles.%s` 填 `closing`,'
|
|
3299
|
+
'不要按页型名称猜。'
|
|
3300
|
+
% (last_page, last_archetype['name'], last_archetype['name']))
|
|
878
3301
|
if leftover:
|
|
879
3302
|
L += ['', '未归入 archetype 的页:%s —— 都是单页孤例,需要就自己补一个 archetype。'
|
|
880
3303
|
% ', '.join(map(str, leftover))]
|
|
881
|
-
L += ['', '
|
|
882
|
-
for a in
|
|
883
|
-
|
|
3304
|
+
L += ['', '有样张页型的 slot 原文(据此起中文页型名,并在 text_roles 判断文本角色):', '']
|
|
3305
|
+
for a in sampled_archetypes:
|
|
3306
|
+
pages = sample_pages(a)
|
|
3307
|
+
representative = a.get('rep') or (pages[0] if pages else None)
|
|
3308
|
+
L.append('- `%s`(第 %s 页,覆盖 %s)' % (a['name'], representative, pages))
|
|
884
3309
|
for s in a['slots']:
|
|
885
3310
|
L.append(' - %s %spx 「%s」' % (s['role'], round(s['sz']), s['txt']))
|
|
886
3311
|
L += ['', '## 下一步', '',
|
|
887
|
-
'1.
|
|
888
|
-
'2.
|
|
3312
|
+
'1. 并行看全部 `vision-group-*.jpg` 和 `layout-sheet.png`;'
|
|
3313
|
+
'2. 先填 asset_vision_groups,再用少量 asset_decisions 写例外,最后一次批量改完其它 TODO;'
|
|
3314
|
+
'3. 只改 `layout-controls.yaml` 的版式判断项,再跑 `package.py`。']
|
|
889
3315
|
write(os.path.join(ldir, 'BRIEF.md'), '\n'.join(L) + '\n')
|
|
890
3316
|
|
|
891
3317
|
|
|
@@ -898,46 +3324,158 @@ def main(argv=None):
|
|
|
898
3324
|
ldir = os.path.join(outdir, 'l-out')
|
|
899
3325
|
os.makedirs(ldir, exist_ok=True)
|
|
900
3326
|
|
|
901
|
-
|
|
3327
|
+
all_shapes = json.load(open(os.path.join(outdir, 'ref', 'shapes.json'),
|
|
3328
|
+
encoding='utf-8'))['shapes']
|
|
3329
|
+
cusage = color_usage(all_shapes, d)
|
|
3330
|
+
tokens, rest, rows = draft_colors(d, cusage)
|
|
902
3331
|
fonts = draft_fonts(d)
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
3332
|
+
effective_alpha = fullscreen_effective_alpha(d, outdir, all_shapes)
|
|
3333
|
+
archetypes, pages, leftover = draft_layouts(d, outdir, effective_alpha)
|
|
3334
|
+
# 只有模板已声明 cover 页型时才能直读它的封面背景。首页和末页会单独保留样张,
|
|
3335
|
+
# 但它们的角色仍由模型看图判断,不能因为页码就自动升格为 cover / closing。
|
|
3336
|
+
cover_media = cover_background_media(archetypes)
|
|
3337
|
+
exported_media = {m['media'] for m in d.get('media', []) if m.get('exported')}
|
|
3338
|
+
bg_needed = {a['bg_raw'] for a in archetypes if a['bg_raw'] in exported_media}
|
|
3339
|
+
bg_under = {p['no']: p.get('rendered_bg') or p['bg_media'] for p in pages}
|
|
3340
|
+
assets, rejected, todos, alias, pool = draft_assets(
|
|
3341
|
+
d, outdir, bg_needed, cover_media, bg_under, effective_alpha)
|
|
908
3342
|
media_to_asset = {a['src']['media']: a['id'] for a in assets}
|
|
3343
|
+
for m, w in (alias or {}).items():
|
|
3344
|
+
if w in media_to_asset:
|
|
3345
|
+
media_to_asset.setdefault(m, media_to_asset[w])
|
|
3346
|
+
|
|
3347
|
+
cW, cH = d['canvas']['px']
|
|
909
3348
|
for a in archetypes:
|
|
910
3349
|
a['bg'] = media_to_asset.get(a['bg_raw'])
|
|
3350
|
+
# 版式自带的图片元素:映射到资产 id。映射不到时**保留槽位但不写 asset**——
|
|
3351
|
+
# 删掉整条槽,消费端看到的是一个没有图标的托底圆,和图标不进包是同一个失败模式,
|
|
3352
|
+
# 而且它连「这里本来有东西」都不知道。
|
|
3353
|
+
keep = []
|
|
3354
|
+
for s in a['slots']:
|
|
3355
|
+
if s.get('media'):
|
|
3356
|
+
aid = media_to_asset.get(s['media'])
|
|
3357
|
+
if not aid:
|
|
3358
|
+
c = pool.get(alias.get(s['media'], s['media'])) or pool.get(s['media'])
|
|
3359
|
+
s['role'] = 'asset-candidate'
|
|
3360
|
+
if c:
|
|
3361
|
+
s['source_media'] = c['file']
|
|
3362
|
+
s.pop('media', None)
|
|
3363
|
+
keep.append(s)
|
|
3364
|
+
continue
|
|
3365
|
+
s['asset'] = aid
|
|
3366
|
+
c = pool.get(alias.get(s['media'], s['media'])) or pool.get(s['media'])
|
|
3367
|
+
if c:
|
|
3368
|
+
s['source_media'] = c['file']
|
|
3369
|
+
# role 跟着资产走:图标槽写成 logo 会让消费端把它当品牌标识,每页都摆一个
|
|
3370
|
+
s['role'] = next((x['kind'] for x in assets if x['id'] == aid), s['role'])
|
|
3371
|
+
keep.append(s)
|
|
3372
|
+
a['slots'] = keep
|
|
911
3373
|
roles = draft_scale(d, archetypes)
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
3374
|
+
slot_added = cover_slot_colors(tokens, archetypes, rows, cusage)
|
|
3375
|
+
# 局部图和半透明满屏叠加层必须结合页面语境定性。候选在本阶段按图片槽过滤:
|
|
3376
|
+
# 没有最终槽位的媒体无需让模型判断;有槽位但超出视觉预算的则保留通用 pic 槽。
|
|
3377
|
+
decided_c = sorted([a['src'] for a in assets], key=lambda c: (-c['n'], c['file']))
|
|
3378
|
+
other_c = sorted([c for c, _ in rejected], key=lambda c: (-c['n'], c['file']))
|
|
3379
|
+
cands, seen_file = [], set()
|
|
3380
|
+
for c in decided_c + other_c: # 同一张图可能有多条候选记录(不同位置各一条)
|
|
3381
|
+
if c['file'] not in seen_file:
|
|
3382
|
+
seen_file.add(c['file'])
|
|
3383
|
+
cands.append(c)
|
|
3384
|
+
review_candidates = []
|
|
3385
|
+
for candidate_index, candidate in enumerate(
|
|
3386
|
+
visual_slot_candidates(cands, archetypes), 1):
|
|
3387
|
+
row = dict(candidate)
|
|
3388
|
+
row['id'] = 'asset-%d' % candidate_index
|
|
3389
|
+
review_candidates.append(row)
|
|
3390
|
+
selected_vision_groups, omitted_vision_groups, sheets = emit_asset_vision_groups(
|
|
3391
|
+
outdir, review_candidates, d['counts']['slides'], ldir)
|
|
915
3392
|
lsheet = layout_sheet(outdir, archetypes, os.path.join(ldir, 'layout-sheet.png'))
|
|
916
3393
|
|
|
917
3394
|
anchors = draft_anchors(d, tokens, fonts, roles, assets, archetypes)
|
|
918
3395
|
gaps, exceptions = [], []
|
|
3396
|
+
if omitted_vision_groups:
|
|
3397
|
+
omitted_pages = sorted({
|
|
3398
|
+
page for group in omitted_vision_groups for page in group['pages'] if page > 0
|
|
3399
|
+
})
|
|
3400
|
+
if omitted_pages:
|
|
3401
|
+
gaps.append('视觉略过:%s页' % '/'.join(map(str, omitted_pages)))
|
|
3402
|
+
else:
|
|
3403
|
+
gaps.append('视觉判断超预算,未覆盖版式候选')
|
|
3404
|
+
if review_candidates and not sheets:
|
|
3405
|
+
gaps.append('视觉拼版不可用,图片候选按内容图保留,未做风格定性。')
|
|
919
3406
|
for c, why in rejected:
|
|
920
3407
|
if '近全透明' in why:
|
|
921
3408
|
gaps.append('母版/版式里的 %s 是%s,不是设计资产,任何情况下不要当背景用。' % (c['file'], why))
|
|
922
|
-
|
|
923
|
-
|
|
3409
|
+
elif '不是背景' in why:
|
|
3410
|
+
gaps.append('%s 在模板里铺满整页,但%s;那几页的真实背景是幻灯片自身的底色,'
|
|
3411
|
+
'需要时按 Colors 里的 surface 铺纯色。' % (c['file'], why))
|
|
3412
|
+
by_kind = {}
|
|
3413
|
+
for kind, kept, total, advice, where in _TRUNCATED:
|
|
3414
|
+
e = by_kind.setdefault(kind, {'kept': 0, 'total': 0, 'advice': advice, 'where': []})
|
|
3415
|
+
e['kept'] += kept
|
|
3416
|
+
e['total'] += total
|
|
3417
|
+
if where:
|
|
3418
|
+
e['where'].append(where)
|
|
3419
|
+
for kind, e in by_kind.items():
|
|
3420
|
+
at = ('(%s)' % '、'.join(e['where'][:6])) if e['where'] else ''
|
|
3421
|
+
gaps.append('%s%s按名额截断:普查到 %d 个,包内留了 %d 个%s。'
|
|
3422
|
+
% (kind, at, e['total'], e['kept'],
|
|
3423
|
+
';' + e['advice'] if e['advice'] else ''))
|
|
3424
|
+
# 「没命中映射表」不等于「装不上」:降级目标本身(Noto Sans SC 之类)和 Office 出厂体
|
|
3425
|
+
# 都不在 match 列里,但它们本来就可用。真正危险的是**既没命中、又不是已知可用字体**的
|
|
3426
|
+
# 那种——design.md 的字体栈里留着一个消费端装不上的商业字体名,且没有任何降级说明。
|
|
3427
|
+
web_ok = {norm(x) for fam in parse_fallback_table() for x in fam['fallback']}
|
|
3428
|
+
web_ok |= {norm(x.strip().strip('"')) for x in SYS_FALLBACK.split(',')}
|
|
3429
|
+
for f in fonts:
|
|
3430
|
+
if f.get('mapped'):
|
|
924
3431
|
gaps.append('源字体 %s 无 web 授权源,已按 font-fallback 表降级到 %s;字形细节与原稿有差异。'
|
|
925
3432
|
% (f['names'][0], f['stack'][1]))
|
|
3433
|
+
elif norm(f['names'][0]) in OFFICE_DEFAULT_FONTS_NORM:
|
|
3434
|
+
gaps.append('%s 是 Office 出厂字体,多半是模板里没清干净的残留而非设计选型;'
|
|
3435
|
+
'按正文/标题的实际气质挑替代体,不要照抄它。' % f['names'][0])
|
|
3436
|
+
elif norm(f['names'][0]) not in web_ok:
|
|
3437
|
+
gaps.append('源字体 %s 不在 font-fallback 表里,字体栈只有原名,消费端很可能装不上;'
|
|
3438
|
+
'按气质挑一个有 web 分发源的近似体补进栈,不要照抄原名。' % f['names'][0])
|
|
3439
|
+
nosize = [(a['name'], s['box']) for a in archetypes for s in a['slots']
|
|
3440
|
+
if not s.get('asset') and not s.get('_font_size')]
|
|
3441
|
+
if nosize:
|
|
3442
|
+
gaps.append('这些文字槽在源文件任何层级都没有字号声明(都不是占位符,是普通文本框,'
|
|
3443
|
+
'继承源是 presentation.xml 的 defaultTextStyle,本抽取按约定不解继承链):'
|
|
3444
|
+
'%s。用 typography 里最接近的档位,不要自造新档。'
|
|
3445
|
+
% '、'.join('%s %s' % (n, b) for n, b in nosize[:6]))
|
|
3446
|
+
|
|
926
3447
|
if leftover:
|
|
927
3448
|
exceptions.append('源 deck 第 %s 页是单页孤例,没有归纳成 archetype;需要类似构图时按最接近的页型改。'
|
|
928
3449
|
% '、'.join(map(str, leftover)))
|
|
929
3450
|
|
|
930
|
-
emit_manifest(d, assets, ldir)
|
|
3451
|
+
emit_manifest(d, assets, selected_vision_groups, ldir, archetypes)
|
|
931
3452
|
emit_frontmatter(d, tokens, fonts, roles, anchors, gaps, ldir)
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
3453
|
+
# 每张背景量一次局部对比度,作为「哪里不能压文字」的客观依据摆进判断单。
|
|
3454
|
+
# 只报测到的数,不替人填 avoid——哪块算主体、要不要避让,是看图才能定的。
|
|
3455
|
+
busy_hints = {}
|
|
3456
|
+
for a in assets:
|
|
3457
|
+
if a['kind'] != 'background' or not a['src'].get('out'):
|
|
3458
|
+
continue
|
|
3459
|
+
r = bg_busy_map(os.path.join(outdir, a['src']['out']), (cW, cH))
|
|
3460
|
+
if r:
|
|
3461
|
+
busy_hints[a['id']] = r
|
|
3462
|
+
facts, recipes = structure_facts(archetypes, d, all_shapes)
|
|
3463
|
+
for a in archetypes:
|
|
3464
|
+
a['flow'] = draft_flow(a, facts.get(a['name']) or {}, (cW, cH))
|
|
3465
|
+
emit_layouts(archetypes, ldir, busy_hints, facts, recipes)
|
|
3466
|
+
emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, ldir,
|
|
3467
|
+
has_asset_candidates=any(needs_asset_judgment(c) for c in cands))
|
|
3468
|
+
emit_brief(d, (tokens, rest, fonts, roles, assets, rejected, todos, archetypes, cands, sheets,
|
|
3469
|
+
selected_vision_groups, omitted_vision_groups, leftover, lsheet), ldir)
|
|
3470
|
+
|
|
3471
|
+
# 这几行落在模型判断「skill 是不是做完了」的那一刻。只报数就会被读成「包已生成」,
|
|
3472
|
+
# 于是判断和打包整段被跳过,deck 拿不到任何版式坐标。所以这里报进度与下一条命令。
|
|
3473
|
+
print('第 1/3 步完成,判断单草案 -> %s' % ldir)
|
|
3474
|
+
print(' 待你确认:资产 %d(%s) 版式 %d 色 %d 字体 %d'
|
|
939
3475
|
% (len(assets), ', '.join(x['id'] for x in assets), len(archetypes), len(tokens), len(fonts)))
|
|
940
|
-
print('
|
|
3476
|
+
print(' 第 2 步 读 l-out/BRIEF.md,并行看视觉组拼版与版式图;版式判断只改 layout-controls.yaml')
|
|
3477
|
+
print(' 第 3 步 package.py 产出 design.md + layouts.md —— deck 的版式坐标只从这两份读')
|
|
3478
|
+
sys.stdout.flush()
|
|
941
3479
|
return 0
|
|
942
3480
|
|
|
943
3481
|
|