@lark-apaas/coding-steering 0.1.18-dev.21ea0ba → 0.1.18-dev.28c4f05
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/steering/design-html/skills/pptx-style-extract/SKILL.md +27 -13
- package/steering/design-html/skills/pptx-style-extract/font-fallback.yaml +3 -3
- package/steering/design-html/skills/pptx-style-extract/scripts/census.py +18 -12
- package/steering/design-html/skills/pptx-style-extract/scripts/check_v2.py +13 -6
- package/steering/design-html/skills/pptx-style-extract/scripts/draft.py +957 -197
- package/steering/design-html/skills/pptx-style-extract/scripts/extract.py +48 -7
- package/steering/design-html/skills/pptx-style-extract/scripts/ooxml.py +1 -1
- package/steering/design-html/skills/pptx-style-extract/scripts/package.py +115 -149
- package/steering/design-html/skills/pptx-style-extract/scripts/parts.py +3 -3
- package/steering/design-html/skills/pptx-style-extract/scripts/query.py +2 -2
- package/steering/design-html/skills/pptx-style-extract/scripts/render_pages.py +16 -10
- package/steering/design-html/skills/pptx-style-extract/v2-format-spec.md +14 -11
|
@@ -19,6 +19,14 @@ import shutil
|
|
|
19
19
|
import sys
|
|
20
20
|
from collections import Counter, defaultdict
|
|
21
21
|
|
|
22
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
23
|
+
from ooxml import OFFICE_DEFAULT_FONTS # noqa: E402
|
|
24
|
+
from census import (ASSET_WARN_SINGLE, FULLSCREEN_COVERAGE, LUM_MID, # noqa: E402
|
|
25
|
+
REPEAT_MIN, SMALL_IMG_W_PCT, canvas_coverage)
|
|
26
|
+
|
|
27
|
+
FILL_MANY = 5 # 「被大量当填充铺开」的次数下限,用于区分卡片底与偶发用色
|
|
28
|
+
BG_CONTENT_CAP = 5 # 内容页背景收几张:再多消费端也挑不过来,超出的写进 TODO 交人取舍
|
|
29
|
+
|
|
22
30
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
23
31
|
SKILL_ROOT = os.path.dirname(HERE)
|
|
24
32
|
SYS_FALLBACK = '"PingFang SC", "Microsoft YaHei", sans-serif'
|
|
@@ -82,7 +90,64 @@ def q(v):
|
|
|
82
90
|
|
|
83
91
|
|
|
84
92
|
# ---------------------------------------------------------------- 颜色
|
|
85
|
-
def
|
|
93
|
+
def bg_colors(d):
|
|
94
|
+
"""页面/版式/母版的 `background` 声明里出现的底色,按声明次数排序。
|
|
95
|
+
|
|
96
|
+
「哪个色是底色」是直读事实(bgPr / bgRef),不用靠亮度猜:渐变里出现的浅色,
|
|
97
|
+
亮度可能比真底色更像底色。
|
|
98
|
+
"""
|
|
99
|
+
cnt = Counter()
|
|
100
|
+
rows = (d.get('slides') or []) + (d.get('layouts') or []) \
|
|
101
|
+
+ ((d.get('masters') or {}).get('masters') or [])
|
|
102
|
+
for row in rows:
|
|
103
|
+
bg = row.get('background')
|
|
104
|
+
if not isinstance(bg, dict):
|
|
105
|
+
continue
|
|
106
|
+
cols = []
|
|
107
|
+
if isinstance(bg.get('color'), dict):
|
|
108
|
+
cols.append(bg['color'])
|
|
109
|
+
for st in (bg.get('stops') or []):
|
|
110
|
+
if isinstance(st.get('color'), dict):
|
|
111
|
+
cols.append(st['color'])
|
|
112
|
+
for c in cols:
|
|
113
|
+
h = (c.get('hex') or '').upper()
|
|
114
|
+
if h:
|
|
115
|
+
cnt[h] += 1
|
|
116
|
+
return [h for h, _ in cnt.most_common()]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _gap_cut(vals, lo, hi):
|
|
120
|
+
"""在排序后的值里找最大间隙,切点取间隙中点。
|
|
121
|
+
|
|
122
|
+
不用中位数:中位数会正好落在某个样本自己身上,它归哪边就只取决于写 >= 还是 >,
|
|
123
|
+
纯属任意。真正的分界在两族之间的空档里。夹在 [lo, hi] 内,避免整套同色的模板
|
|
124
|
+
把界推到极端。
|
|
125
|
+
"""
|
|
126
|
+
v = sorted(vals)
|
|
127
|
+
if len(v) < 2:
|
|
128
|
+
return (lo + hi) / 2.0
|
|
129
|
+
_, mid = max((v[i + 1] - v[i], (v[i + 1] + v[i]) / 2.0) for i in range(len(v) - 1))
|
|
130
|
+
return min(max(mid, lo), hi)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def palette_cuts(rows):
|
|
134
|
+
"""「有彩 vs 中性」「深 vs 浅」的分界,按本模板自己的色分布切。
|
|
135
|
+
|
|
136
|
+
固定分界必然错一边:低饱和的莫兰迪配色整套都在低位,高饱和的品牌配色整套都在高位。
|
|
137
|
+
"""
|
|
138
|
+
sat_cut = _gap_cut([r['sat'] for r in rows], 0.12, 0.45)
|
|
139
|
+
lums = sorted(r['lum'] for r in rows) or [0.0]
|
|
140
|
+
return sat_cut, lums[len(lums) // 2]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def draft_colors(d, cusage=None):
|
|
144
|
+
"""色板 token:名字按**实际用法**定,不只看亮度饱和度。
|
|
145
|
+
|
|
146
|
+
只看 lum/sat 会把「主要用来填色的纯黑」命名成 ink(文字色)、把「只出现在渐变里的
|
|
147
|
+
浅蓝」命名成 surface-alt。这里先看它在形状上主要干什么,再结合
|
|
148
|
+
亮度定名;用量太少的直接不进色板。
|
|
149
|
+
"""
|
|
150
|
+
cusage = cusage or {}
|
|
86
151
|
pool = [c for c in d['color_freq']
|
|
87
152
|
if c.get('class') == 'design' and abs((c.get('alpha') or 100) - 100) < 0.1]
|
|
88
153
|
seen, rows = set(), []
|
|
@@ -92,29 +157,61 @@ def draft_colors(d):
|
|
|
92
157
|
continue
|
|
93
158
|
seen.add(h)
|
|
94
159
|
rgb = hex2rgb(h)
|
|
95
|
-
|
|
96
|
-
|
|
160
|
+
u = cusage.get(h) or Counter()
|
|
161
|
+
tot = sum(u.values())
|
|
162
|
+
main = u.most_common(1)[0][0] if tot else None
|
|
163
|
+
rows.append({'hex': h, 'n': c['n'], 'lum': lum(rgb), 'sat': satu(rgb),
|
|
164
|
+
'use': u, 'use_n': tot, 'main': main})
|
|
165
|
+
# 用量只用来**命名**,不作准入门槛——color_usage 只数形状级的填充/描边/文字,
|
|
166
|
+
# 背景 p:bg 与主题色不在其中,拿它筛会把色板砍到只剩极少数几个。
|
|
167
|
+
strong = sorted(rows, key=lambda r: -r['n'])
|
|
168
|
+
|
|
169
|
+
SAT_CUT, LUM_CUT = palette_cuts(rows)
|
|
97
170
|
|
|
98
171
|
tokens, used = [], set()
|
|
99
172
|
|
|
100
173
|
def take(pred, names):
|
|
101
174
|
for name in names:
|
|
102
|
-
for r in
|
|
175
|
+
for r in strong:
|
|
103
176
|
if r['hex'] in used or not pred(r):
|
|
104
177
|
continue
|
|
105
178
|
used.add(r['hex'])
|
|
106
179
|
tokens.append((name, r))
|
|
107
180
|
break
|
|
108
181
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
182
|
+
def kind(r):
|
|
183
|
+
if r['main'] == '文字':
|
|
184
|
+
return 'text'
|
|
185
|
+
if r['main'] in ('填充', '渐变', '描边'):
|
|
186
|
+
return 'paint'
|
|
187
|
+
return 'unknown' # 形状层看不到用法,退回亮度/饱和度判断
|
|
188
|
+
|
|
189
|
+
# 墨色:主要用来写字(或看不出用法但本身是深中性色),且不是彩色
|
|
190
|
+
take(lambda r: r['sat'] < SAT_CUT and r['lum'] < min(LUM_CUT, LUM_MID)
|
|
191
|
+
and (kind(r) == 'text' or kind(r) == 'unknown'), ['ink', 'ink-muted'])
|
|
192
|
+
# 底色:直接取页面 background 声明里的色,按声明次数排
|
|
193
|
+
grounds = bg_colors(d)
|
|
194
|
+
for name in ('surface', 'surface-alt'):
|
|
195
|
+
for h in grounds:
|
|
196
|
+
r = next((x for x in strong if x['hex'] == h and x['hex'] not in used), None)
|
|
197
|
+
if r:
|
|
198
|
+
used.add(r['hex'])
|
|
199
|
+
tokens.append((name, r))
|
|
200
|
+
break
|
|
201
|
+
|
|
202
|
+
# 卡片/面板底:页面底色之外,真被大量当填充铺开的浅色(≥5 处才算)
|
|
203
|
+
take(lambda r: r['lum'] > max(LUM_CUT, 0.85) and r['sat'] < SAT_CUT
|
|
204
|
+
and (r['use'].get('填充') or 0) >= FILL_MANY, ['surface-raised'])
|
|
205
|
+
# 表达色:有彩度的按频次排
|
|
206
|
+
take(lambda r: r['sat'] >= SAT_CUT, ['primary', 'accent', 'accent-2', 'accent-3'])
|
|
207
|
+
# 其余低饱和色一律 neutral-N——它到底是卡片底、分隔线还是描边,数据分不出来,
|
|
208
|
+
# 就不要用名字去替消费方下结论;真实用法写在 Colors 表的用途列里。
|
|
209
|
+
take(lambda r: r['sat'] < SAT_CUT, ['neutral', 'neutral-2', 'neutral-3'])
|
|
114
210
|
rest = [r for r in rows if r['hex'] not in used][:6]
|
|
115
211
|
return tokens, rest, rows
|
|
116
212
|
|
|
117
213
|
|
|
214
|
+
|
|
118
215
|
# ---------------------------------------------------------------- 字体
|
|
119
216
|
def parse_fallback_table():
|
|
120
217
|
path = os.path.join(SKILL_ROOT, 'font-fallback.yaml')
|
|
@@ -142,6 +239,48 @@ def norm(s):
|
|
|
142
239
|
return re.sub(r'[\s\-_]', '', s or '').lower()
|
|
143
240
|
|
|
144
241
|
|
|
242
|
+
OFFICE_DEFAULT_FONTS_NORM = {norm(x) for x in OFFICE_DEFAULT_FONTS}
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def cover_slot_colors(tokens, archetypes, rows, cusage):
|
|
246
|
+
"""slot 里出现的每个色值都必须在色板里有名字。
|
|
247
|
+
|
|
248
|
+
Hard Rules 写「颜色只用 colors 里的 token」,而 slot 的 color 是从模板直读的,
|
|
249
|
+
两者不对齐就等于产物自己违反自己的规则——slot 的色值直读自模板,未必都已进
|
|
250
|
+
色板。这里把缺的补进色板,按用法归族命名。
|
|
251
|
+
"""
|
|
252
|
+
have = {r['hex'].upper() for _, r in tokens}
|
|
253
|
+
by_hex = {r['hex'].upper(): r for r in rows}
|
|
254
|
+
sat_cut, lum_cut = palette_cuts(rows) # 与 draft_colors 同一套切点,别各切各的
|
|
255
|
+
used = [n for n, _ in tokens]
|
|
256
|
+
|
|
257
|
+
def nxt(fam):
|
|
258
|
+
if fam not in used:
|
|
259
|
+
return fam
|
|
260
|
+
i = 2
|
|
261
|
+
while '%s-%d' % (fam, i) in used:
|
|
262
|
+
i += 1
|
|
263
|
+
return '%s-%d' % (fam, i)
|
|
264
|
+
|
|
265
|
+
added = []
|
|
266
|
+
for a in archetypes:
|
|
267
|
+
for s in a['slots']:
|
|
268
|
+
h = (s.get('color') or '').upper()
|
|
269
|
+
if not h.startswith('#') or h in have:
|
|
270
|
+
continue
|
|
271
|
+
have.add(h)
|
|
272
|
+
r = by_hex.get(h)
|
|
273
|
+
if r is None: # 普查里没有这个色(理论上不该发生),跳过不编造
|
|
274
|
+
continue
|
|
275
|
+
fam = ('ink' if r['sat'] < sat_cut and r['lum'] < min(lum_cut, LUM_MID)
|
|
276
|
+
else 'accent' if r['sat'] >= sat_cut else 'neutral')
|
|
277
|
+
name = nxt(fam)
|
|
278
|
+
used.append(name)
|
|
279
|
+
tokens.append((name, r))
|
|
280
|
+
added.append((name, h))
|
|
281
|
+
return added
|
|
282
|
+
|
|
283
|
+
|
|
145
284
|
def draft_fonts(d):
|
|
146
285
|
table = parse_fallback_table()
|
|
147
286
|
groups = defaultdict(lambda: {'rendered': 0, 'weights': set(), 'names': set(), 'bold': 0})
|
|
@@ -201,35 +340,184 @@ def import_line(fonts):
|
|
|
201
340
|
return "@import url('%s?%s&display=swap');" % (MIRROR, fam), webs
|
|
202
341
|
|
|
203
342
|
|
|
343
|
+
def quant(hit, total):
|
|
344
|
+
"""覆盖率决定量词——不到一半就不许说「一律/每页」。"""
|
|
345
|
+
if not total:
|
|
346
|
+
return None
|
|
347
|
+
r = hit / float(total)
|
|
348
|
+
if r >= 0.9:
|
|
349
|
+
return '一律'
|
|
350
|
+
if r >= 0.5:
|
|
351
|
+
return '多数'
|
|
352
|
+
return None
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def color_usage(shapes, d=None):
|
|
356
|
+
"""每个色值在形状上的真实用法计数:填充 / 渐变 / 描边 / 文字。
|
|
357
|
+
|
|
358
|
+
用途列不能靠预设字典猜——同一个色在不同模板里的主用途完全不同。这里从 shapes
|
|
359
|
+
直接数,数不到就如实说数不到。
|
|
360
|
+
"""
|
|
361
|
+
def hx(c):
|
|
362
|
+
return (c.get('hex') or '').upper() if isinstance(c, dict) else ''
|
|
363
|
+
|
|
364
|
+
def walk_text_colors(node, out):
|
|
365
|
+
"""文本样式可能嵌在 lstStyle.lvlNpPr / defRPr / rPr 任一层——通用遍历,
|
|
366
|
+
别逐层枚举(枚举漏过 lvl2pPr,导致主色被写成「用途待确认」)。"""
|
|
367
|
+
if isinstance(node, dict):
|
|
368
|
+
if isinstance(node.get('color'), dict) and hx(node['color']):
|
|
369
|
+
out.append(hx(node['color']))
|
|
370
|
+
for v in node.values():
|
|
371
|
+
walk_text_colors(v, out)
|
|
372
|
+
elif isinstance(node, list):
|
|
373
|
+
for v in node:
|
|
374
|
+
walk_text_colors(v, out)
|
|
375
|
+
|
|
376
|
+
use = defaultdict(Counter)
|
|
377
|
+
for s in shapes:
|
|
378
|
+
f = s.get('fill') or {}
|
|
379
|
+
if f.get('type') == 'solid' and hx(f.get('color')):
|
|
380
|
+
use[hx(f['color'])]['填充'] += 1
|
|
381
|
+
for st in (f.get('stops') or []):
|
|
382
|
+
if hx(st.get('color')):
|
|
383
|
+
use[hx(st['color'])]['渐变'] += 1
|
|
384
|
+
ln = s.get('line') or {}
|
|
385
|
+
if hx(ln.get('color')):
|
|
386
|
+
use[hx(ln['color'])]['描边'] += 1
|
|
387
|
+
cols = []
|
|
388
|
+
walk_text_colors(s.get('text') or {}, cols)
|
|
389
|
+
for h in cols:
|
|
390
|
+
use[h]['文字'] += 1
|
|
391
|
+
for h in bg_colors(d or {}):
|
|
392
|
+
use[h]['页面背景'] += 1
|
|
393
|
+
# 主题 clrScheme:这类色常常只在主题里声明、页面上由 schemeClr 间接引用,
|
|
394
|
+
# 不记上就会在用途列写「未落在形状上」,看着像没人用。
|
|
395
|
+
for th in ((d or {}).get('themes') or []):
|
|
396
|
+
if not th.get('picked'):
|
|
397
|
+
continue
|
|
398
|
+
for slot, hexv in (th.get('clrScheme') or {}).items():
|
|
399
|
+
if isinstance(hexv, str) and hexv.startswith('#'):
|
|
400
|
+
use[hexv.upper()]['主题 ' + slot] += 1
|
|
401
|
+
return use
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def usage_phrase(counter):
|
|
405
|
+
"""把用法计数写成一句话;主用法占六成以上就直接点名,否则并列前三。"""
|
|
406
|
+
if not counter:
|
|
407
|
+
return '普查里有声明,但未落在形状/背景/主题色上——用途待确认'
|
|
408
|
+
items = counter.most_common()
|
|
409
|
+
tot = sum(counter.values())
|
|
410
|
+
if items[0][1] >= tot * 0.6:
|
|
411
|
+
return '主要作%s(%d/%d 处)' % (items[0][0], items[0][1], tot)
|
|
412
|
+
return '、'.join('%s %d 处' % (k, v) for k, v in items[:3])
|
|
413
|
+
|
|
414
|
+
|
|
204
415
|
def draft_anchors(d, tokens, fonts, roles, assets, archetypes):
|
|
205
|
-
|
|
416
|
+
"""anchors 逐条由统计覆盖率产出;证据不足就不生成这一条,不用形容词补。"""
|
|
417
|
+
A = []
|
|
418
|
+
n_arch = len(archetypes) or 1
|
|
419
|
+
|
|
420
|
+
# 1. 表达色:未取用的高频彩色要如实带上,不能说「其余全是中性」
|
|
421
|
+
names = [x[0] for x in tokens]
|
|
206
422
|
chroma = [n for n in names if n.startswith(('primary', 'accent'))]
|
|
423
|
+
if chroma:
|
|
424
|
+
A.append((chroma[0] + '-led-palette', 'token',
|
|
425
|
+
'表达色集中在 %s;其余 token 为底色与文字色' % '、'.join(chroma[:3])))
|
|
426
|
+
|
|
427
|
+
# 2. 圆角:按普查占比
|
|
207
428
|
radii = d.get('radii_census') or []
|
|
208
429
|
zero = next((r for r in radii if r['px'] == 0), None)
|
|
209
|
-
|
|
210
|
-
|
|
430
|
+
tot_r = sum(r['n'] for r in radii) or 1
|
|
431
|
+
if zero:
|
|
432
|
+
q0 = quant(zero['n'], tot_r)
|
|
433
|
+
if q0:
|
|
434
|
+
A.append(('zero-radius', 'token',
|
|
435
|
+
'卡片、按钮、面板%s直角,圆角量为零的形状占 %d%%'
|
|
436
|
+
% (q0, round(100.0 * zero['n'] / tot_r))))
|
|
437
|
+
|
|
438
|
+
# 3. 满屏底图:按有背景的页型占比
|
|
439
|
+
with_bg = sum(1 for a in archetypes if a.get('bg'))
|
|
440
|
+
qb = quant(with_bg, n_arch)
|
|
441
|
+
if qb:
|
|
442
|
+
A.append(('full-bleed-ground', 'pattern',
|
|
443
|
+
'页型%s由整幅铺满的底图打底(%d/%d),元素浮在图上而不是浮在纯色块上'
|
|
444
|
+
% (qb, with_bg, n_arch)))
|
|
445
|
+
|
|
446
|
+
# 4. 标识:位置是不是真的固定,看有几个不同的 box
|
|
447
|
+
logo_slots = [s for a in archetypes for s in a['slots']
|
|
448
|
+
if str(s.get('asset') or '').startswith(('logo', 'slogan'))]
|
|
449
|
+
logo_arch = sum(1 for a in archetypes
|
|
450
|
+
if any(str(s.get('asset') or '').startswith(('logo', 'slogan'))
|
|
451
|
+
for s in a['slots']))
|
|
452
|
+
boxes = {tuple(s['box']) for s in logo_slots}
|
|
453
|
+
if logo_arch and len(boxes) == 1:
|
|
454
|
+
A.append(('corner-locked-logo', 'component',
|
|
455
|
+
'品牌标识在 %d/%d 个页型上出现,位置尺寸完全一致,跨页不动'
|
|
456
|
+
% (logo_arch, n_arch)))
|
|
457
|
+
elif len(boxes) > 1:
|
|
458
|
+
A.append(('logo-moves-by-archetype', 'component',
|
|
459
|
+
'品牌标识按页型换位换尺寸(共 %d 种摆法),必须按 layouts 里该页型的 box 放,'
|
|
460
|
+
'不能沿用上一页' % len(boxes)))
|
|
461
|
+
|
|
462
|
+
# 5. 渐变:按普查计数
|
|
463
|
+
if (d.get('geom_census') or {}).get('gradient_fills'):
|
|
464
|
+
A.append(('gradient-accent', 'pattern',
|
|
465
|
+
'强调元素用线性渐变承载,全档共 %d 处渐变填充'
|
|
466
|
+
% (d['geom_census']['gradient_fills'])))
|
|
467
|
+
|
|
468
|
+
# 6. 层级:字号跨度 + 字重是否单一(字重真单一才敢说「不靠字重」)
|
|
469
|
+
disp, body = roles.get('display'), roles.get('body')
|
|
470
|
+
if disp and body and disp['sz_px'] > body['sz_px']:
|
|
471
|
+
ws = {s.get('weight') for a in archetypes for s in a['slots'] if s.get('weight')}
|
|
472
|
+
tail = (',字重只用 %s 一档' % list(ws)[0]) if len(ws) == 1 else ''
|
|
473
|
+
A.append(('size-driven-hierarchy', 'pattern',
|
|
474
|
+
'层级靠字号跨度拉开,展示档与正文档差 %.1f 倍,见 typography%s'
|
|
475
|
+
% (disp['sz_px'] / body['sz_px'], tail)))
|
|
476
|
+
|
|
477
|
+
# 7. 阴影:只在描边极少时才敢说「不用描边分隔」
|
|
211
478
|
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
479
|
if eff.get('outerShdw'):
|
|
227
|
-
A.append(('soft-shadow-card', 'component',
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
480
|
+
A.append(('soft-shadow-card', 'component',
|
|
481
|
+
'容器用外阴影托起,全档 %d 处 outerShdw' % eff['outerShdw']))
|
|
482
|
+
|
|
483
|
+
# 8. 双字族:只陈述分工存在,不断言「同一行混排」(普查没采集混排)
|
|
484
|
+
tot_r_font = sum(f['rendered'] for f in fonts) or 1
|
|
485
|
+
if len(fonts) >= 2 and fonts[1]['rendered']:
|
|
486
|
+
A.append(('dual-family-typesetting', 'token',
|
|
487
|
+
'正文与展示分属两套字族:%s 与 %s,各自渲染 %d / %d 处'
|
|
488
|
+
% (fonts[0]['names'][0], fonts[1]['names'][0],
|
|
489
|
+
fonts[0]['rendered'], fonts[1]['rendered'])))
|
|
490
|
+
|
|
491
|
+
# 9. 安全区:只在各页型正文左边界真的收敛时才写
|
|
492
|
+
# 「多宽算正文槽」按本包自己的槽宽分布定:固定 px 门槛在窄版心模板上会一个都不剩
|
|
493
|
+
widths = sorted(s['box'][2] for a in archetypes for s in a['slots'] if not s.get('asset'))
|
|
494
|
+
w_cut = widths[len(widths) // 2] if widths else 0
|
|
495
|
+
lefts = [s['box'][0] for a in archetypes for s in a['slots']
|
|
496
|
+
if not s.get('asset') and s['box'][2] >= w_cut]
|
|
497
|
+
if len(lefts) >= 4:
|
|
498
|
+
common = Counter(lefts).most_common(1)[0]
|
|
499
|
+
qs = quant(common[1], len(lefts))
|
|
500
|
+
if qs:
|
|
501
|
+
A.append(('shared-left-margin', 'token',
|
|
502
|
+
'正文%s对齐同一条左边界(%d/%d 个正文槽共用,坐标见 layouts)'
|
|
503
|
+
% (qs, common[1], len(lefts))))
|
|
504
|
+
|
|
505
|
+
# 10. 双主题:直读事实
|
|
506
|
+
themes = (d.get('theme_topology') or {}).get('themes') or []
|
|
507
|
+
if len(themes) > 1:
|
|
508
|
+
A.append(('dual-theme-masters', 'token',
|
|
509
|
+
'模板带 %s 两套主题母版,同一页型有深浅两版,配色随主题整体反转'
|
|
510
|
+
% ' / '.join(themes)))
|
|
511
|
+
|
|
512
|
+
# 11. 画布:直读事实(兜底凑数也只用真事实)
|
|
513
|
+
cv = d['canvas']['px']
|
|
514
|
+
A.append(('fixed-canvas', 'token',
|
|
515
|
+
'画布固定 %d×%d,所有坐标是这张画布上的绝对像素,不做响应式重排'
|
|
516
|
+
% (cv[0], cv[1])))
|
|
517
|
+
if len(archetypes) >= 3:
|
|
518
|
+
A.append(('archetype-catalog', 'pattern',
|
|
519
|
+
'模板给出 %d 种页型,搭页从中挑,不要自创版式' % len(archetypes)))
|
|
520
|
+
|
|
233
521
|
seen, out = set(), []
|
|
234
522
|
for a in A:
|
|
235
523
|
if a[0] in seen:
|
|
@@ -238,8 +526,6 @@ def draft_anchors(d, tokens, fonts, roles, assets, archetypes):
|
|
|
238
526
|
out.append(a)
|
|
239
527
|
return out[:8]
|
|
240
528
|
|
|
241
|
-
|
|
242
|
-
# ---------------------------------------------------------------- 字号轴
|
|
243
529
|
def draft_scale(d, archetypes=()):
|
|
244
530
|
ts = [t for t in d['text_scale'] if t['sz_px'] >= 10]
|
|
245
531
|
ts.sort(key=lambda t: -t['sz_px'])
|
|
@@ -251,8 +537,9 @@ def draft_scale(d, archetypes=()):
|
|
|
251
537
|
display = by_px.get(max(title_sz)) if title_sz else None
|
|
252
538
|
big = [t for t in ts if t['n'] >= 2] or ts
|
|
253
539
|
display = display or big[0]
|
|
254
|
-
|
|
255
|
-
|
|
540
|
+
# 正文档 = 渲染次数最多的那一档。不设「多大算正文」的上限:大字号排版的模板
|
|
541
|
+
# 正文本来就可能比别的模板的标题还大,预设上限会把它整档判错。
|
|
542
|
+
body = max([t for t in ts if t is not display] or ts, key=lambda t: t['n'])
|
|
256
543
|
heading_pool = [t for t in ts if body['sz_px'] * 1.3 <= t['sz_px'] < display['sz_px']]
|
|
257
544
|
heading = max(heading_pool, key=lambda t: t['n']) if heading_pool else None
|
|
258
545
|
small_pool = [t for t in ts if t['sz_px'] < body['sz_px']]
|
|
@@ -347,6 +634,7 @@ def draft_assets(d, outdir, bg_needed=(), cover_media=None, bg_under=None):
|
|
|
347
634
|
cands.append({
|
|
348
635
|
'media': m['media'], 'file': os.path.basename(out_rel), 'out': out_rel,
|
|
349
636
|
'bytes': m.get('bytes'), 'n': img.get('n', m.get('used_n', 0)),
|
|
637
|
+
'has_compressed': bool(m.get('compressed_out')),
|
|
350
638
|
'fullscreen': bool(img.get('fullscreen')), 'w_pct': img.get('max_w_pct', 0),
|
|
351
639
|
'box': top.get('box') or {}, 'slides': slides,
|
|
352
640
|
'layer_only': bool(parts) and not slides,
|
|
@@ -362,12 +650,21 @@ def draft_assets(d, outdir, bg_needed=(), cover_media=None, bg_under=None):
|
|
|
362
650
|
if k not in best_of or c['n'] > best_of[k]['n']:
|
|
363
651
|
best_of[k] = c
|
|
364
652
|
kept = sorted(best_of.values(), key=lambda c: (-c['n'], -(c['bytes'] or 0)))
|
|
653
|
+
# 被同簇兄弟淘汰的 media 仍要能指到胜出者——封面底图常常是簇里 n 最小的那张
|
|
654
|
+
alias = {}
|
|
655
|
+
for c in cands:
|
|
656
|
+
w = best_of.get(c['cluster'] or c['media'])
|
|
657
|
+
if w and w['media'] != c['media']:
|
|
658
|
+
alias[c['media']] = w['media']
|
|
659
|
+
cover_media = alias.get(cover_media, cover_media)
|
|
660
|
+
bg_needed = {alias.get(m, m) for m in (bg_needed or ())}
|
|
365
661
|
|
|
366
662
|
assets, rejected, todos = [], [], []
|
|
663
|
+
over_cap_bgs = []
|
|
367
664
|
logo_pool = []
|
|
368
665
|
bg_under = bg_under or {}
|
|
369
666
|
bg_i = 0
|
|
370
|
-
canvas_w = d['canvas']['px']
|
|
667
|
+
canvas_w, canvas_h = d['canvas']['px']
|
|
371
668
|
for c in kept:
|
|
372
669
|
if c['probe'].get('near_blank'):
|
|
373
670
|
rejected.append((c, '近全透明(alpha 均值 %.0f/255),PPT 里看不见' % c['probe']['alpha_mean']))
|
|
@@ -375,22 +672,27 @@ def draft_assets(d, outdir, bg_needed=(), cover_media=None, bg_under=None):
|
|
|
375
672
|
if c['fullscreen']:
|
|
376
673
|
if c['media'] == cover_media:
|
|
377
674
|
assets.append({'id': 'bg-cover', 'kind': 'background', 'role': 'cover',
|
|
378
|
-
'src': c,
|
|
379
|
-
|
|
675
|
+
'src': c,
|
|
676
|
+
# 只有真出了压缩版才能带原图;否则 path/full 指向同一
|
|
677
|
+
# 文件,package.py 必 FAIL(封面不需要转码时就会踩到)
|
|
678
|
+
'use_full': c['has_compressed']})
|
|
679
|
+
elif c['media'] in bg_needed and bg_i < BG_CONTENT_CAP:
|
|
380
680
|
bg_i += 1
|
|
381
681
|
assets.append({'id': 'bg-content-%d' % bg_i, 'kind': 'background',
|
|
382
682
|
'role': 'content', 'src': c, 'use_full': False})
|
|
683
|
+
elif c['media'] in bg_needed:
|
|
684
|
+
over_cap_bgs.append(c)
|
|
685
|
+
rejected.append((c, '有页型以它为主底,但内容页背景已收满 %d 张' % BG_CONTENT_CAP))
|
|
383
686
|
else:
|
|
384
687
|
rejected.append((c, '满屏图但没有页面以它为主底(只在版式层备用)'))
|
|
385
|
-
elif c['w_pct'] <
|
|
688
|
+
elif c['w_pct'] < SMALL_IMG_W_PCT and c['n'] >= REPEAT_MIN:
|
|
689
|
+
# 品牌标识的共性是「小、重复出现、贴角」。这里只按贴角程度排序给出首选,
|
|
690
|
+
# 不设及格线——「多少分算 logo」没有客观依据,判断交 L 层,分项证据随 TODO 给出。
|
|
386
691
|
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))
|
|
692
|
+
edge_x = min(b.get('x', 0), max(canvas_w - (b.get('x', 0) + (b.get('w') or 0)), 0))
|
|
693
|
+
edge_y = min(b.get('y', 0), max(canvas_h - (b.get('y', 0) + (b.get('h') or 0)), 0))
|
|
694
|
+
corner = (edge_x / canvas_w) + (edge_y / canvas_h) # 越小越贴角
|
|
695
|
+
logo_pool.append((corner, c))
|
|
394
696
|
else:
|
|
395
697
|
rejected.append((c, '内容区图片(占宽 %.0f%%,出现 %d 次)' % (c['w_pct'], c['n'])))
|
|
396
698
|
|
|
@@ -404,42 +706,358 @@ def draft_assets(d, outdir, bg_needed=(), cover_media=None, bg_under=None):
|
|
|
404
706
|
from PIL import Image
|
|
405
707
|
im = Image.open(os.path.join(outdir, row['out'])).convert('RGB')
|
|
406
708
|
b = c['box']
|
|
407
|
-
sx, sy = im.width / float(canvas_w), im.height / float(
|
|
709
|
+
sx, sy = im.width / float(canvas_w), im.height / float(canvas_h)
|
|
408
710
|
crop = im.crop((int(b.get('x', 0) * sx), int(b.get('y', 0) * sy),
|
|
409
711
|
max(int((b.get('x', 0) + b.get('w', 1)) * sx), 1),
|
|
410
712
|
max(int((b.get('y', 0) + b.get('h', 1)) * sy), 1))).resize((16, 16))
|
|
411
713
|
raw = crop.tobytes()
|
|
412
714
|
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) >
|
|
715
|
+
return 'light' if sum(lum(p) for p in px) / len(px) > LUM_MID else 'dark'
|
|
414
716
|
except Exception:
|
|
415
717
|
return None
|
|
416
718
|
|
|
417
|
-
|
|
418
|
-
|
|
719
|
+
# 贴角是品牌标识的定义性特征:离两边都超过画布 1/4 的重复小图,更可能是页内装饰。
|
|
720
|
+
# 这不是「多少分算 logo」那种凑出来的分数线——它直接来自「贴角」这个判据本身。
|
|
721
|
+
LOGO_CORNER_MAX = 0.5 # edge_x/W + edge_y/H,两边各 25% 即到上限
|
|
722
|
+
logo_pool.sort(key=lambda kv: (kv[0], -kv[1]['n']))
|
|
723
|
+
if logo_pool and logo_pool[0][0] > LOGO_CORNER_MAX:
|
|
724
|
+
todos.append('没有贴角的重复小图(最接近的一张离画布边 %.0f%%),本模板可能没有 logo;'
|
|
725
|
+
'确认后要么从联系表挑一张补进 manifest,要么在 gaps 写明模板无品牌标识'
|
|
726
|
+
% (logo_pool[0][0] * 50))
|
|
727
|
+
logo_pool = []
|
|
728
|
+
for i, (corner, c) in enumerate(logo_pool):
|
|
419
729
|
b = c['box']
|
|
420
|
-
if i == 0
|
|
730
|
+
if i == 0:
|
|
421
731
|
assets.append({'id': 'logo-primary', 'kind': 'logo', 'role': None, 'src': c,
|
|
422
732
|
'use_full': False, 'on_bg': on_bg_of(c)})
|
|
423
|
-
todos.append('看联系表确认 `%s
|
|
733
|
+
todos.append('看联系表确认 `%s` 真是品牌 logo(%.0fx%.0f @ %.0f,%.0f,出现 %d 次,'
|
|
734
|
+
'离画布边 %.0f%%,是所有小图里最贴角的一张);'
|
|
424
735
|
'不是就把 manifest 的 logo-primary 换成别的候选或整条删掉'
|
|
425
|
-
% (c['file'], b.get('w', 0), b.get('h', 0), b.get('x', 0), b.get('y', 0),
|
|
736
|
+
% (c['file'], b.get('w', 0), b.get('h', 0), b.get('x', 0), b.get('y', 0),
|
|
737
|
+
c['n'], corner * 50))
|
|
738
|
+
else:
|
|
739
|
+
rejected.append((c, '重复小图(%.0fx%.0f @ %.0f,%.0f),贴角程度 %.0f%% 不如首选'
|
|
740
|
+
% (b.get('w', 0), b.get('h', 0), b.get('x', 0), b.get('y', 0),
|
|
741
|
+
corner * 50)))
|
|
742
|
+
|
|
743
|
+
# 体量预算:包内资产总量超 20MB 直接 FAIL(V2-6)。`use_full` 的原图是唯一可能
|
|
744
|
+
# 单张爆预算的东西(未压缩的封面级大图可以单张达到数十 MB),所以在草案期就先丢 full,
|
|
745
|
+
# 不要留给 L 层去撞门禁再回修。
|
|
746
|
+
PACK_BUDGET = 20 * 1024 * 1024
|
|
747
|
+
est = sum(min(a['src'].get('bytes') or 0, ASSET_WARN_SINGLE) for a in assets)
|
|
748
|
+
for a in sorted([x for x in assets if x['use_full']],
|
|
749
|
+
key=lambda x: -(x['src'].get('bytes') or 0)):
|
|
750
|
+
orig = a['src'].get('bytes') or 0
|
|
751
|
+
if est + orig > PACK_BUDGET * 0.9:
|
|
752
|
+
a['use_full'] = False
|
|
753
|
+
todos.append('`%s` 的原图 %.1fMB 会把包撑过 20MB 上限,草案已只保留压缩版;'
|
|
754
|
+
'确实需要原图就改走 url 承载' % (a['id'], orig / 1024.0 / 1024))
|
|
426
755
|
else:
|
|
427
|
-
|
|
428
|
-
% (b.get('w', 0), b.get('h', 0), b.get('x', 0), b.get('y', 0))))
|
|
756
|
+
est += orig
|
|
429
757
|
|
|
758
|
+
if over_cap_bgs:
|
|
759
|
+
todos.append('模板有 %d 张内容页背景超出 %d 张上限(%s);用到它们的页型在 layouts.md 里'
|
|
760
|
+
'不会有 background,需要就手工补进 manifest 并删掉不重要的那几张'
|
|
761
|
+
% (len(over_cap_bgs), BG_CONTENT_CAP,
|
|
762
|
+
'、'.join(c['file'] for c in over_cap_bgs[:5])))
|
|
430
763
|
if not any(a['role'] == 'cover' for a in assets):
|
|
431
764
|
todos.append('没定出封面底图——从联系表挑一张补进 manifest(role: cover),或在 gaps 写明模板无封面主视觉')
|
|
432
765
|
copy_logo_candidates(outdir, logo_pool)
|
|
433
|
-
return assets, rejected, todos
|
|
766
|
+
return assets, rejected, todos, alias, {c['media']: c for c in kept}
|
|
434
767
|
|
|
435
768
|
|
|
436
769
|
# ---------------------------------------------------------------- 版式聚类
|
|
437
770
|
DECOR_MIN = 40.0
|
|
438
771
|
|
|
772
|
+
# 版式名 → role(模板自己按页型命名时直接用它,别再猜)。
|
|
773
|
+
# 英文词按整词匹配:裸子串会让短词吃掉长词——`end` 一度把 `agenda`、`Appendix`、
|
|
774
|
+
# `Trends Section` 全判成 closing,表里 `agenda -> section` 那条永远轮不到。
|
|
775
|
+
ROLE_BY_WORD = [('封面', 'cover'), ('cover', 'cover'), ('首页', 'cover'),
|
|
776
|
+
('title slide', 'cover'), ('标题幻灯片', 'cover'),
|
|
777
|
+
('封底', 'closing'), ('尾页', 'closing'), ('结束', 'closing'),
|
|
778
|
+
('致谢', 'closing'), ('谢谢', 'closing'), ('end', 'closing'),
|
|
779
|
+
('thank you', 'closing'), ('closing', 'closing'),
|
|
780
|
+
('章节', 'section'), ('目录', 'section'), ('过渡', 'section'),
|
|
781
|
+
('section', 'section'), ('agenda', 'section'),
|
|
782
|
+
('section header', 'section'), ('节标题', 'section'),
|
|
783
|
+
('金句', 'quote'), ('问句', 'quote'), ('引言', 'quote'), ('quote', 'quote'),
|
|
784
|
+
('空白', 'blank'), ('blank', 'blank')]
|
|
785
|
+
PH_TO_TYPE = {'title': 'title', 'ctrTitle': 'title', 'subTitle': 'subtitle',
|
|
786
|
+
'body': 'body', 'pic': 'pic', 'clipArt': 'pic', 'tbl': 'table',
|
|
787
|
+
'chart': 'chart', 'media': 'media', 'dgm': 'pic',
|
|
788
|
+
'sldNum': 'slide-number', 'ftr': 'footer', 'dt': 'footer'}
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
def role_of_name(name):
|
|
792
|
+
"""版式名 → role。认不出返回 None,由调用方降置信度并留 TODO——不要静默当 content。
|
|
793
|
+
|
|
794
|
+
词表只覆盖中英文;换一种语言命名的模板会整份认不出。那时全落 content 且机检照过,
|
|
795
|
+
消费端拿到的是「每一页都是内容页」,封面/章节/结束页的语义整个丢掉且无处可查。
|
|
796
|
+
"""
|
|
797
|
+
low = (name or '').lower()
|
|
798
|
+
for word, role in ROLE_BY_WORD:
|
|
799
|
+
if word.isascii():
|
|
800
|
+
if re.search(r'(?<![a-z])%s(?![a-z])' % re.escape(word), low):
|
|
801
|
+
return role
|
|
802
|
+
elif word in low:
|
|
803
|
+
return role
|
|
804
|
+
return None
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def clean_layout_name(name):
|
|
808
|
+
"""`1_内容-左右排版(无副标题)` → `内容-左右排版(无副标题)`。"""
|
|
809
|
+
return re.sub(r'^\d+[_\-\s]*', '', (name or '').strip()) or '未命名版式'
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def slot_style(s):
|
|
813
|
+
"""占位符自带的排版样式——字号/色值/对齐/字重都是直读,不给消费端留编的空间。
|
|
814
|
+
|
|
815
|
+
样式可能在三层:lstStyle.lvl1pPr(版式占位符常用)、段落 defRPr(Mac Office
|
|
816
|
+
导出把大量属性写在这一层)、段落 pPr(对齐)。逐层兜底,缺一层就往下取。
|
|
817
|
+
"""
|
|
818
|
+
txt = s.get('text') or {}
|
|
819
|
+
ls = dict((txt.get('lstStyle') or {}).get('lvl1pPr') or {})
|
|
820
|
+
# 四层逐级兜底,按 OOXML 的就近原则:run rPr → 段落 defRPr → 段落 pPr → lstStyle。
|
|
821
|
+
# 只枚举前几层会整份漏掉——有的导出器把字号全写在 run rPr 上,lstStyle 一个都没有。
|
|
822
|
+
for para in (txt.get('paragraphs') or []):
|
|
823
|
+
srcs = [r.get('rPr') or {} for r in (para.get('runs') or [])]
|
|
824
|
+
srcs.append(para.get('defRPr') or {})
|
|
825
|
+
srcs.append({k: v for k, v in para.items() if k not in ('runs', 'defRPr')})
|
|
826
|
+
for src in srcs:
|
|
827
|
+
for k, v in (src or {}).items():
|
|
828
|
+
if v is not None:
|
|
829
|
+
ls.setdefault(k, v)
|
|
830
|
+
if ls.get('sz_px'):
|
|
831
|
+
break
|
|
832
|
+
if not ls.get('sz_px'):
|
|
833
|
+
# 仍无声明:退到整形状里出现过的最大字号(generic walk),仍是文件里的值
|
|
834
|
+
anysz = shape_sz(s)
|
|
835
|
+
if anysz:
|
|
836
|
+
ls['sz_px'] = anysz
|
|
837
|
+
out = {}
|
|
838
|
+
if ls.get('sz_px'):
|
|
839
|
+
out['size'] = round(ls['sz_px'])
|
|
840
|
+
col = (ls.get('color') or {}).get('resolved')
|
|
841
|
+
if col:
|
|
842
|
+
out['color'] = col
|
|
843
|
+
if ls.get('weight'):
|
|
844
|
+
out['weight'] = ls['weight']
|
|
845
|
+
elif ls.get('bold'):
|
|
846
|
+
out['weight'] = 700
|
|
847
|
+
if ls.get('algn') and ls['algn'] not in ('l', 'just'):
|
|
848
|
+
out['align'] = {'ctr': 'center', 'r': 'right'}.get(ls['algn'], ls['algn'])
|
|
849
|
+
anchor = ((s.get('text') or {}).get('bodyPr') or {}).get('anchor')
|
|
850
|
+
if anchor in ('ctr', 'b'):
|
|
851
|
+
out['valign'] = {'ctr': 'middle', 'b': 'bottom'}[anchor]
|
|
852
|
+
return out
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
def instance_override(shapes, slide_part, slots, bgm, cW, cH):
|
|
856
|
+
"""实例页覆盖版式:版式是骨架,实例页才是设计师最终摆定的样子。
|
|
857
|
+
|
|
858
|
+
版式底图常是多个版式共用的通用底纹,实例页可能另铺主视觉大图;标题占位符的框高
|
|
859
|
+
也常被实例页放大以容纳多行。只读版式的包会让消费端拿到错的底图和装不下字的框,
|
|
860
|
+
只能自己缩字号。
|
|
861
|
+
"""
|
|
862
|
+
ins = [s for s in shapes if s.get('part') == slide_part]
|
|
863
|
+
if not ins:
|
|
864
|
+
return slots, bgm
|
|
865
|
+
for s in ins: # 实例页自己铺的满屏图优先
|
|
866
|
+
if (s.get('kind') == 'pic' and s.get('media')
|
|
867
|
+
and s.get('w_pct', 0) >= 95 and s.get('h_pct', 0) >= 95):
|
|
868
|
+
bgm = s['media']
|
|
869
|
+
break
|
|
870
|
+
texts = []
|
|
871
|
+
for s in ins:
|
|
872
|
+
b = s.get('box') or {}
|
|
873
|
+
if not (b.get('w') and b.get('h')) or not shape_text(s):
|
|
874
|
+
continue
|
|
875
|
+
texts.append({'sz': shape_sz(s), 'box': b, 'style': slot_style(s)})
|
|
876
|
+
texts.sort(key=lambda x: -x['sz'])
|
|
877
|
+
# 按字号大小依次顶替版式的文字槽(版式槽已按 y 排过,字号序更贴合语义层级)
|
|
878
|
+
tslots = [s for s in slots if s['type'] != 'pic']
|
|
879
|
+
for slot, ins_t in zip(sorted(tslots, key=lambda s: -(s.get('sz') or 0)), texts):
|
|
880
|
+
b = ins_t['box']
|
|
881
|
+
slot['box'] = [round(b.get('x', 0)), round(b.get('y', 0)),
|
|
882
|
+
round(b.get('w', 0)), round(b.get('h', 0))]
|
|
883
|
+
slot['sz'] = ins_t['sz']
|
|
884
|
+
slot.update(ins_t['style'] or {})
|
|
885
|
+
return slots, bgm
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
def layouts_from_template(d, shapes, cW, cH):
|
|
889
|
+
"""form=3:模板自己用 slideLayout 声明了页型,直接读版式层。
|
|
890
|
+
|
|
891
|
+
拿样张聚类只能得到「样张数」个 archetype——模板往往只放 1-2 张样张,
|
|
892
|
+
真正的页型全在版式里。模板常见只放个位数样张却声明几十个语义版式,按样张聚类
|
|
893
|
+
只能得到「样张数」个 archetype,消费端搭页时大半无版式可抄,只能自己编。
|
|
894
|
+
"""
|
|
895
|
+
by_part = defaultdict(list)
|
|
896
|
+
for s in shapes:
|
|
897
|
+
if s.get('layer') == 'layout' and s.get('ph'):
|
|
898
|
+
by_part[s['part']].append(s)
|
|
899
|
+
bg_of_layout = {}
|
|
900
|
+
for s in shapes:
|
|
901
|
+
if (s.get('layer') == 'layout' and s.get('kind') == 'pic'
|
|
902
|
+
and s.get('w_pct', 0) >= 95 and s.get('h_pct', 0) >= 95):
|
|
903
|
+
bg_of_layout.setdefault(s['part'], s.get('media'))
|
|
904
|
+
topo = d.get('theme_topology') or {}
|
|
905
|
+
theme_of_master = {m['master']: m.get('theme_label')
|
|
906
|
+
for m in (topo.get('per_master') or [])}
|
|
907
|
+
master_of = (d.get('reference_graph') or {}).get('master_of_layout') or {}
|
|
908
|
+
# 只在版式恰好被 1 张实例页使用时才拿实例覆盖:多张实例共用一个版式时,
|
|
909
|
+
# 谁都不代表版式本身,硬挑一张会把别页的构图当成页型
|
|
910
|
+
lay_of_slide = (d.get('reference_graph') or {}).get('layout_of_slide') or {}
|
|
911
|
+
used_n = Counter(lay_of_slide.values())
|
|
912
|
+
slide_of_layout = {lp: sp for sp, lp in lay_of_slide.items() if used_n[lp] == 1}
|
|
913
|
+
default_theme = topo.get('default')
|
|
914
|
+
multi = len(topo.get('themes') or []) > 1
|
|
915
|
+
|
|
916
|
+
rows = []
|
|
917
|
+
for l in d.get('layouts') or []:
|
|
918
|
+
phs = [s for s in by_part.get(l['part'], []) if (s.get('box') or {}).get('w')]
|
|
919
|
+
if not phs:
|
|
920
|
+
continue
|
|
921
|
+
theme = theme_of_master.get(master_of.get(l['part']))
|
|
922
|
+
phs.sort(key=lambda s: ((s['box'].get('y') or 0), (s['box'].get('x') or 0)))
|
|
923
|
+
slots, seen_kind = [], set()
|
|
924
|
+
for s in phs:
|
|
925
|
+
t = PH_TO_TYPE.get((s['ph'] or {}).get('type'), 'body')
|
|
926
|
+
if t in ('slide-number', 'footer'):
|
|
927
|
+
continue # 页码/页脚属 chrome,不是内容槽
|
|
928
|
+
b = s['box']
|
|
929
|
+
role = t if t in ('title', 'subtitle') else 'body'
|
|
930
|
+
if t == 'title' and 'title' in seen_kind:
|
|
931
|
+
role, t = 'subtitle', 'subtitle'
|
|
932
|
+
seen_kind.add(t)
|
|
933
|
+
row = {'role': role, 'type': t, 'sz': shape_sz(s),
|
|
934
|
+
'box': [round(b.get('x', 0)), round(b.get('y', 0)),
|
|
935
|
+
round(b.get('w', 0)), round(b.get('h', 0))],
|
|
936
|
+
'txt': shape_text(s) or (s.get('name') or '')[:24]}
|
|
937
|
+
row.update(slot_style(s))
|
|
938
|
+
slots.append(row)
|
|
939
|
+
# 非满屏的图片元素(logo / 联名标 / 装饰)——它们逐版式换位置换尺寸,
|
|
940
|
+
# 必须按版式落进 slots,压成一条全局「固定位」规则就会撞标题。
|
|
941
|
+
bgm = bg_of_layout.get(l['part'])
|
|
942
|
+
for s in shapes:
|
|
943
|
+
if s['part'] != l['part'] or s.get('kind') != 'pic' or not s.get('media'):
|
|
944
|
+
continue
|
|
945
|
+
if s['media'] == bgm or (s.get('w_pct', 0) >= 95 and s.get('h_pct', 0) >= 95):
|
|
946
|
+
continue
|
|
947
|
+
b = s.get('box') or {}
|
|
948
|
+
if not b.get('w'):
|
|
949
|
+
continue
|
|
950
|
+
slots.append({'role': 'logo', 'type': 'pic', 'sz': 0, 'txt': '',
|
|
951
|
+
'media': s['media'],
|
|
952
|
+
'box': [round(b.get('x', 0)), round(b.get('y', 0)),
|
|
953
|
+
round(b.get('w', 0)), round(b.get('h', 0))]})
|
|
954
|
+
if not slots:
|
|
955
|
+
continue
|
|
956
|
+
inst = slide_of_layout.get(l['part'])
|
|
957
|
+
if inst:
|
|
958
|
+
slots, bgm = instance_override(shapes, inst, slots, bgm, cW, cH)
|
|
959
|
+
taken = {tuple(s['box']) for s in slots}
|
|
960
|
+
decor = collect_decor(shapes, inst or l['part'], taken, (cW, cH))
|
|
961
|
+
named_role = role_of_name(l.get('name'))
|
|
962
|
+
rows.append({'zh': clean_layout_name(l.get('name')),
|
|
963
|
+
'role': named_role or 'content', 'role_guessed': named_role is None,
|
|
964
|
+
'slots': slots, 'decor': decor, 'bg_raw': bgm,
|
|
965
|
+
'theme': theme, 'part': l['part'],
|
|
966
|
+
'used': l.get('used_by_slides') or 0})
|
|
967
|
+
|
|
968
|
+
# 同名版式在 dark/light 两套 master 下各有一份——按名字归一,优先默认主题那份
|
|
969
|
+
best = {}
|
|
970
|
+
for r in rows:
|
|
971
|
+
k = r['zh']
|
|
972
|
+
cur = best.get(k)
|
|
973
|
+
if cur is None or (r['theme'] == default_theme and cur['theme'] != default_theme) \
|
|
974
|
+
or (r['used'] > cur['used']):
|
|
975
|
+
best[k] = r
|
|
976
|
+
picked = sorted(best.values(), key=lambda r: (
|
|
977
|
+
['cover', 'section', 'quote', 'content', 'closing', 'blank'].index(r['role'])
|
|
978
|
+
if r['role'] in ('cover', 'section', 'quote', 'content', 'closing', 'blank') else 9,
|
|
979
|
+
-r['used'], r['part']))
|
|
980
|
+
|
|
981
|
+
used_key = Counter()
|
|
982
|
+
arch = []
|
|
983
|
+
for r in picked:
|
|
984
|
+
used_key[r['role']] += 1
|
|
985
|
+
n = used_key[r['role']]
|
|
986
|
+
key = r['role'] if n == 1 else '%s-%d' % (r['role'], n)
|
|
987
|
+
m_no = re.search(r'slideLayout(\d+)\.xml$', r['part'])
|
|
988
|
+
arch.append({'name': key, 'zh': r['zh'], 'role': r['role'], 'bg': None,
|
|
989
|
+
'role_guessed': r.get('role_guessed'),
|
|
990
|
+
'bg_raw': r['bg_raw'], 'slots': r['slots'],
|
|
991
|
+
'decor': r.get('decor') or [], 'pages': [],
|
|
992
|
+
'rep': None, 'rep_layout': int(m_no.group(1)) if m_no else None,
|
|
993
|
+
# 版式名认不出 role 时不装作有把握:置信度降到 low,让 L 层看图定
|
|
994
|
+
'pic_n': 0, 'confidence': 'low' if r.get('role_guessed') else 'high',
|
|
995
|
+
'theme': r['theme'] if multi else None,
|
|
996
|
+
'source': 'layout:' + r['part'].split('/')[-1]})
|
|
997
|
+
return arch
|
|
998
|
+
|
|
999
|
+
|
|
1000
|
+
_QUERY = []
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
def _load_query():
|
|
1004
|
+
"""复用 query.py 的 OOXML→CSS 渲染,不再写第二份。"""
|
|
1005
|
+
if not _QUERY:
|
|
1006
|
+
import importlib.util
|
|
1007
|
+
spec = importlib.util.spec_from_file_location('_q', os.path.join(HERE, 'query.py'))
|
|
1008
|
+
mod = importlib.util.module_from_spec(spec)
|
|
1009
|
+
spec.loader.exec_module(mod)
|
|
1010
|
+
_QUERY.append(mod)
|
|
1011
|
+
return _QUERY[0]
|
|
1012
|
+
|
|
1013
|
+
|
|
1014
|
+
def collect_decor(shapes, part, taken_boxes, canvas, limit=10):
|
|
1015
|
+
"""页面上撑起版式骨架、但不含文字的形状(圆形图标托、卡片、分隔线)。
|
|
1016
|
+
|
|
1017
|
+
只给文字框的坐标,消费端看到的是「一段说明悬在半空、上方一片空白」,只能自己编
|
|
1018
|
+
容器,编出来的形状与模板无关。这些形状必须进包。
|
|
1019
|
+
"""
|
|
1020
|
+
q = _load_query()
|
|
1021
|
+
cW, cH = canvas
|
|
1022
|
+
out = []
|
|
1023
|
+
for s in shapes:
|
|
1024
|
+
if s.get('part') != part or s.get('kind') != 'sp':
|
|
1025
|
+
continue
|
|
1026
|
+
if any(r.get('text', '').strip()
|
|
1027
|
+
for para in ((s.get('text') or {}).get('paragraphs') or [])
|
|
1028
|
+
for r in (para.get('runs') or [])):
|
|
1029
|
+
continue # 有文字的已经作为 slot 出过
|
|
1030
|
+
b = s.get('box') or {}
|
|
1031
|
+
w, h = b.get('w') or 0, b.get('h') or 0
|
|
1032
|
+
if not (w or h):
|
|
1033
|
+
continue # 零尺寸形状渲染不出任何东西
|
|
1034
|
+
if canvas_coverage(b, cW, cH) >= FULLSCREEN_COVERAGE:
|
|
1035
|
+
continue # 满屏底,属 background
|
|
1036
|
+
box = [round(b.get('x', 0)), round(b.get('y', 0)), round(w), round(h)]
|
|
1037
|
+
if tuple(box) in taken_boxes:
|
|
1038
|
+
continue
|
|
1039
|
+
css = q._recipe_css(s.get('fill'), s.get('line'),
|
|
1040
|
+
[s.get('radius_px')] if s.get('radius_px') else [], s.get('effects'))
|
|
1041
|
+
# 声明要落成单行:含换行的声明会被下游的行式解析器从换行处截断,
|
|
1042
|
+
# 且只记 PARSE-WARN 不 FAIL,整包照常出厂——带着半条渲染不出来的 CSS
|
|
1043
|
+
css = [re.sub(r'\s*\n\s*', ' ', c.split('\x00')[0]).strip() for c in css if c]
|
|
1044
|
+
if not css:
|
|
1045
|
+
continue # 无填充无描边无阴影 = 看不见,不占篇幅
|
|
1046
|
+
out.append({'box': box, 'geom': (s.get('geom') or {}).get('prst') or 'rect',
|
|
1047
|
+
'css': '; '.join(css), 'area': max(w * h, w, h)})
|
|
1048
|
+
# 按面积降序取前 limit 条:撑起版式的结构性形状总在最前,零星噪点自然落在截断线外,
|
|
1049
|
+
# 不需要再设一个「多小算噪点」的尺寸门槛(那种门槛会误杀 1px 分隔线)。
|
|
1050
|
+
out.sort(key=lambda d: -d['area'])
|
|
1051
|
+
return out[:limit] # 同款不同位置都要留,位置本身是版式信息
|
|
1052
|
+
|
|
439
1053
|
|
|
440
1054
|
def draft_layouts(d, outdir):
|
|
441
1055
|
shapes = json.load(open(os.path.join(outdir, 'ref', 'shapes.json'), encoding='utf-8'))['shapes']
|
|
442
1056
|
cW, cH = d['canvas']['px']
|
|
1057
|
+
if (d.get('form_hint') or {}).get('form') == 3:
|
|
1058
|
+
arch = layouts_from_template(d, shapes, cW, cH)
|
|
1059
|
+
if len(arch) >= 3:
|
|
1060
|
+
return arch, [], []
|
|
443
1061
|
by_slide = defaultdict(list)
|
|
444
1062
|
for s in shapes:
|
|
445
1063
|
if s.get('layer') == 'slide':
|
|
@@ -476,30 +1094,40 @@ def draft_layouts(d, outdir):
|
|
|
476
1094
|
b = s.get('box') or {}
|
|
477
1095
|
if b.get('w', 0) < DECOR_MIN or b.get('h', 0) < 16:
|
|
478
1096
|
continue
|
|
479
|
-
texts.append({'sz': shape_sz(s), 'box': b, 'txt': txt})
|
|
1097
|
+
texts.append({'sz': shape_sz(s), 'box': b, 'txt': txt, 'style': slot_style(s)})
|
|
480
1098
|
texts.sort(key=lambda t: (-t['sz'], t['box'].get('y', 0)))
|
|
481
1099
|
pics = [s for s in sh if s.get('kind') == 'pic' and s.get('w_pct', 0) < 95]
|
|
1100
|
+
# 小图元素(logo / 角标 / 装饰)逐页记位置,供 archetype 落 slots
|
|
1101
|
+
marks = [{'media': s['media'], 'box': s['box']} for s in pics
|
|
1102
|
+
if s.get('media') and (s.get('box') or {}).get('w') and s.get('w_pct', 0) < 30]
|
|
482
1103
|
pages.append({'part': part, 'no': slide_no(part), 'bg_media': bg_media,
|
|
483
1104
|
'bg_color': bg_of_slide.get(part), 'texts': texts, 'pic_n': len(pics),
|
|
484
|
-
'shape_n': len(sh)})
|
|
1105
|
+
'marks': marks, 'shape_n': len(sh)})
|
|
1106
|
+
|
|
1107
|
+
# 页型的**角色**(封面 / 章节页 / 内容页……)不在这里判:那是看图才能下的结论,
|
|
1108
|
+
# 交给读得到重建图的模型。脚本只做客观归并——同一张底图 + 文字块数量相近的页
|
|
1109
|
+
# 归成一组,档位按本 deck 自己的分布切,不用「字号 ≥60 就是章节页」这类固定数。
|
|
1110
|
+
ns = sorted(len(p['texts']) for p in pages) or [0]
|
|
1111
|
+
q1, q2 = ns[len(ns) // 3], ns[len(ns) * 2 // 3]
|
|
485
1112
|
|
|
486
|
-
def
|
|
1113
|
+
def density_band(p):
|
|
487
1114
|
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'
|
|
1115
|
+
return 0 if n <= q1 else (1 if n <= q2 else 2)
|
|
496
1116
|
|
|
497
1117
|
groups = defaultdict(list)
|
|
498
1118
|
for p in pages:
|
|
499
|
-
|
|
1119
|
+
if p['no'] == 1:
|
|
1120
|
+
# 首页单独成组:它是 deck 唯一的入口页,版面通常和后面任何一页都不同,
|
|
1121
|
+
# 并进别的组就会被代表页顶掉、坐标全丢。这只是不合并,不代表它是封面。
|
|
1122
|
+
groups[('__first__', -1)] = [p]
|
|
1123
|
+
continue
|
|
1124
|
+
groups[(p['bg_media'] or p['bg_color'] or 'none', density_band(p))].append(p)
|
|
500
1125
|
|
|
501
1126
|
ranked = sorted(groups.items(), key=lambda kv: (-len(kv[1]), kv[1][0]['no']))
|
|
502
|
-
|
|
1127
|
+
# 首页所在的组一定收——deck 的第一页是模板的门面,孤例也不能被名额挤掉。
|
|
1128
|
+
# 这只保证它进包,它是不是封面由看图的人定。
|
|
1129
|
+
first = [g for g in ranked if g[0][0] == '__first__']
|
|
1130
|
+
kept = first + [g for g in ranked if g not in first and len(g[1]) >= 2][:8 - len(first)]
|
|
503
1131
|
for g in ranked: # 名额没用满就把最大的孤例页也收进来
|
|
504
1132
|
if len(kept) >= 8:
|
|
505
1133
|
break
|
|
@@ -508,15 +1136,18 @@ def draft_layouts(d, outdir):
|
|
|
508
1136
|
leftover = sorted(p['no'] for g in ranked if g not in kept for p in g[1])
|
|
509
1137
|
|
|
510
1138
|
archetypes = []
|
|
511
|
-
|
|
512
|
-
for (bg_raw, kind), ps in kept:
|
|
1139
|
+
for gi, ((bg_raw, _band), ps) in enumerate(kept, 1):
|
|
513
1140
|
rep = max(ps, key=lambda p: len(p['texts']))
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
1141
|
+
if bg_raw == '__first__':
|
|
1142
|
+
bg_raw = rep['bg_media'] or rep['bg_color'] or 'none'
|
|
1143
|
+
name = 'layout-%d' % gi
|
|
1144
|
+
# 标题按「位置 + 跨度」认,不按字号——big-number 类的巨号数值常比标题还大
|
|
1145
|
+
# 标题 = 该页最靠上的那批文本里最宽的一块。不按「画布前 28%」这类固定比例切:
|
|
1146
|
+
# 版心靠下的模板会整页认不出标题。以该页自身的文本框分布定「靠上」。
|
|
1147
|
+
ys = sorted(t['box'].get('y', 0) for t in rep['texts'])
|
|
1148
|
+
y_cut = ys[max(len(ys) // 4, 0)] if ys else 0
|
|
1149
|
+
band = [t for t in rep['texts'] if t['box'].get('y', 1e9) <= y_cut]
|
|
1150
|
+
title = max(band, key=lambda t: (t['box'].get('w', 0), t['sz'])) if band else (
|
|
520
1151
|
max(rep['texts'], key=lambda t: t['sz']) if rep['texts'] else None)
|
|
521
1152
|
rest = [t for t in rep['texts'] if t is not title]
|
|
522
1153
|
rest.sort(key=lambda t: (t['box'].get('y', 0), t['box'].get('x', 0)))
|
|
@@ -526,17 +1157,36 @@ def draft_layouts(d, outdir):
|
|
|
526
1157
|
b = t['box']
|
|
527
1158
|
if t is title:
|
|
528
1159
|
role = typ = 'title'
|
|
529
|
-
elif (title and i == 1
|
|
530
|
-
|
|
1160
|
+
elif (title and i == 1
|
|
1161
|
+
# 副标题 = 紧跟在标题下方、与标题左对齐的那一块。三个量都相对标题
|
|
1162
|
+
# 自身:绝对 px 门槛在大字号排版的模板上会整片认不出来。
|
|
1163
|
+
and abs(b.get('x', 0) - title['box'].get('x', 0)) <= title['box'].get('h', 0)
|
|
531
1164
|
and 0 <= b.get('y', 0) - (title['box'].get('y', 0)
|
|
532
|
-
+ title['box'].get('h', 0))
|
|
1165
|
+
+ title['box'].get('h', 0))
|
|
1166
|
+
<= title['box'].get('h', 0) * 2):
|
|
533
1167
|
role = typ = 'subtitle'
|
|
534
1168
|
else:
|
|
535
1169
|
role = typ = 'body'
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
1170
|
+
row = {'role': role, 'box': [round(b.get('x', 0)), round(b.get('y', 0)),
|
|
1171
|
+
round(b.get('w', 0)), round(b.get('h', 0))],
|
|
1172
|
+
'type': typ, 'sz': t['sz'], 'txt': t['txt']}
|
|
1173
|
+
row.update(t.get('style') or {})
|
|
1174
|
+
slots.append(row)
|
|
1175
|
+
# 代表页上的小图元素按位置去重后落 slots(同一 logo 在不同页型位置不同)
|
|
1176
|
+
seen_mark = set()
|
|
1177
|
+
for mk in rep.get('marks') or []:
|
|
1178
|
+
b = mk['box']
|
|
1179
|
+
key = (mk['media'], round(b.get('x', 0)), round(b.get('y', 0)))
|
|
1180
|
+
if key in seen_mark:
|
|
1181
|
+
continue
|
|
1182
|
+
seen_mark.add(key)
|
|
1183
|
+
slots.append({'role': 'logo', 'type': 'pic', 'sz': 0, 'txt': '',
|
|
1184
|
+
'media': mk['media'],
|
|
1185
|
+
'box': [round(b.get('x', 0)), round(b.get('y', 0)),
|
|
1186
|
+
round(b.get('w', 0)), round(b.get('h', 0))]})
|
|
1187
|
+
decor = collect_decor(shapes, rep['part'], {tuple(s['box']) for s in slots}, (cW, cH))
|
|
539
1188
|
archetypes.append({'name': name, 'bg': None, 'bg_raw': bg_raw, 'slots': slots,
|
|
1189
|
+
'decor': decor,
|
|
540
1190
|
'pages': sorted(p['no'] for p in ps), 'rep': rep['no'],
|
|
541
1191
|
'pic_n': rep['pic_n'],
|
|
542
1192
|
'confidence': 'high' if len(ps) >= 3 else
|
|
@@ -547,12 +1197,15 @@ def draft_layouts(d, outdir):
|
|
|
547
1197
|
# ---------------------------------------------------------------- 联系表
|
|
548
1198
|
def layout_sheet(outdir, archetypes, path):
|
|
549
1199
|
"""把各 archetype 的代表页光栅出来拼成一张——版式命名得看得见页面。"""
|
|
550
|
-
|
|
1200
|
+
use_layout = all(a.get('rep') is None for a in archetypes)
|
|
1201
|
+
reps = [a.get('rep_layout') if use_layout else a.get('rep') for a in archetypes]
|
|
1202
|
+
reps = [x for x in reps if x is not None]
|
|
551
1203
|
if not reps:
|
|
552
1204
|
return None
|
|
553
1205
|
import subprocess
|
|
554
1206
|
r = subprocess.run([sys.executable, os.path.join(HERE, 'render_pages.py'), outdir,
|
|
555
|
-
'--pages', '
|
|
1207
|
+
'--pages', 'layouts' if use_layout else 'slides',
|
|
1208
|
+
'--only', ','.join(map(str, reps)), '--no-html'],
|
|
556
1209
|
capture_output=True, text=True)
|
|
557
1210
|
png_dir = os.path.join(outdir, 'ref', 'rebuild', 'png')
|
|
558
1211
|
if r.returncode or not os.path.isdir(png_dir):
|
|
@@ -570,14 +1223,19 @@ def layout_sheet(outdir, archetypes, path):
|
|
|
570
1223
|
for i, a in enumerate(archetypes):
|
|
571
1224
|
x = pad + (i % cols) * (cw + pad)
|
|
572
1225
|
y = pad + (i // cols) * (ch + pad + lab)
|
|
573
|
-
|
|
1226
|
+
no = a.get('rep_layout') if use_layout else a.get('rep')
|
|
1227
|
+
f = os.path.join(png_dir, '%s-%s.png' % ('layout' if use_layout else 'slide', no))
|
|
574
1228
|
if os.path.exists(f):
|
|
575
1229
|
im = Image.open(f).convert('RGB')
|
|
576
1230
|
im.thumbnail((cw, ch))
|
|
577
1231
|
sheet.paste(im, (x, y))
|
|
578
1232
|
dr.rectangle([x, y, x + cw, y + ch], outline=(120, 120, 128))
|
|
579
|
-
|
|
580
|
-
|
|
1233
|
+
# 标注只写 ASCII——Pillow 默认字体没有 CJK 字形,中文会渲染成方框
|
|
1234
|
+
dr.text((x + 2, y + ch + 5), '[%s] %s bg=%s'
|
|
1235
|
+
% (a['name'],
|
|
1236
|
+
('layout %s' % a.get('rep_layout')) if use_layout
|
|
1237
|
+
else ('slide %s x%d pages' % (a.get('rep'), len(a['pages']))),
|
|
1238
|
+
a.get('bg') or '-'),
|
|
581
1239
|
fill=(20, 20, 24))
|
|
582
1240
|
sheet.save(path, optimize=True)
|
|
583
1241
|
return path
|
|
@@ -669,20 +1327,31 @@ def emit_frontmatter(d, tokens, fonts, roles, anchors, gaps, ldir):
|
|
|
669
1327
|
edge = {}
|
|
670
1328
|
for p in pads:
|
|
671
1329
|
edge.setdefault(p['edge'], p['px'])
|
|
672
|
-
|
|
1330
|
+
# 四边都测出来才写 spacing / safe-area。缺一边就整段不写,并在 gaps 说明——
|
|
1331
|
+
# 拿另一套模板的边距当默认值,会让消费端按一个从没在本模板出现过的网格排版。
|
|
1332
|
+
edges_full = all(edge.get(k) is not None for k in ('top', 'right', 'bottom', 'left'))
|
|
1333
|
+
if edges_full:
|
|
673
1334
|
L.append('spacing:')
|
|
674
1335
|
L.append(' page-padding: {top: %s, right: %s, bottom: %s, left: %s}'
|
|
675
|
-
% (edge
|
|
676
|
-
|
|
677
|
-
radii = [r for r in (d.get('radii_census') or []) if r['px'] >=
|
|
1336
|
+
% (edge['top'], edge['right'], edge['bottom'], edge['left']))
|
|
1337
|
+
# 只排除「圆角量为零」(那是直角不是圆角),不再设「出现几次才算数」的门槛
|
|
1338
|
+
radii = [r for r in (d.get('radii_census') or []) if r['px'] >= 1]
|
|
678
1339
|
if radii:
|
|
679
1340
|
top = max(radii, key=lambda r: r['n'])
|
|
680
1341
|
L.append('rounded:')
|
|
681
1342
|
L.append(' card: %dpx' % round(top['px']))
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
1343
|
+
if edges_full:
|
|
1344
|
+
L.append('safe-area:')
|
|
1345
|
+
L.append(' content: {top: %s, right: %s, bottom: %s, left: %s, applies-to: [content]}'
|
|
1346
|
+
% (edge['top'], edge['right'], edge['bottom'], edge['left']))
|
|
1347
|
+
L.append(' confidence: medium')
|
|
1348
|
+
else:
|
|
1349
|
+
gaps = list(gaps) + ['本模板没测出四边都稳定的页边距(普查到 %s),'
|
|
1350
|
+
'因此不给 spacing / safe-area:按各页型 slot 的实际坐标排版,'
|
|
1351
|
+
'不要自造统一边距。'
|
|
1352
|
+
% ('、'.join('%s=%s' % (k, edge[k]) for k in
|
|
1353
|
+
('top', 'right', 'bottom', 'left') if edge.get(k) is not None)
|
|
1354
|
+
or '一边都没有')]
|
|
686
1355
|
L.append('anchors:')
|
|
687
1356
|
for aid, typ, desc in anchors:
|
|
688
1357
|
L.append(' - {id: %s, type: %s, desc: "%s"}' % (aid, typ, desc))
|
|
@@ -693,130 +1362,147 @@ def emit_frontmatter(d, tokens, fonts, roles, anchors, gaps, ldir):
|
|
|
693
1362
|
|
|
694
1363
|
|
|
695
1364
|
def emit_layouts(archetypes, ldir):
|
|
696
|
-
|
|
697
|
-
|
|
1365
|
+
prefilled = sum(1 for a in archetypes if a.get('zh'))
|
|
1366
|
+
L = ['# 只改 names / roles / bg_rules 三段(都是扁平键值,改完 package.py 自动并回各页型)。',
|
|
1367
|
+
'# 下面 layouts 段是普查数值,一个字都不要动——改它容易连带删掉 slots/confidence。']
|
|
1368
|
+
if prefilled:
|
|
1369
|
+
L.append('# names 已按模板自带的版式名填好 %d 条,读一遍确认表意即可,通常不用改。' % prefilled)
|
|
1370
|
+
L.append('names:')
|
|
1371
|
+
for a in archetypes:
|
|
1372
|
+
if a.get('zh'):
|
|
1373
|
+
# 模板自己给版式起了名(form=3),直接用——比看图起名准,也省掉一轮判断
|
|
1374
|
+
L.append(' %s: %s' % (a['name'], q(a['zh'])))
|
|
1375
|
+
else:
|
|
1376
|
+
L.append(' %s: TODO中文名(代表页 %s,共 %d 页)'
|
|
1377
|
+
% (a['name'], a['rep'], len(a['pages'])))
|
|
1378
|
+
# 角色(封面 / 章节页 / 内容页……)是看图才能下的结论,脚本不猜。模板自己按页型
|
|
1379
|
+
# 命名时用它的标注,否则连同客观事实一起摆出来,由看得到重建图的你来定。
|
|
1380
|
+
need_role = [a for a in archetypes if not a.get('role')]
|
|
1381
|
+
if need_role:
|
|
1382
|
+
L.append('roles: # 取值 cover|section|content|quote|closing|blank|custom')
|
|
1383
|
+
for a in need_role:
|
|
1384
|
+
szs = sorted({round(s['sz']) for s in a['slots'] if s.get('sz')}, reverse=True)
|
|
1385
|
+
L.append(' %s: TODO角色 # 代表页 %s,共 %d 页;文字块 %d 个,字号 %s;'
|
|
1386
|
+
'图片 %d 张%s'
|
|
1387
|
+
% (a['name'], a['rep'], len(a['pages']),
|
|
1388
|
+
len([s for s in a['slots'] if not s.get('asset')]),
|
|
1389
|
+
'/'.join(str(x) for x in szs[:5]) or '未声明',
|
|
1390
|
+
a.get('pic_n') or 0, ';有满屏底图' if a.get('bg_raw') else ''))
|
|
1391
|
+
# 禁放区是**背景图**的属性,不是页型的属性——按背景资产分组,页型再多也不涨
|
|
1392
|
+
bgs = []
|
|
698
1393
|
for a in archetypes:
|
|
699
|
-
|
|
1394
|
+
if a['bg'] and a['bg'] not in bgs:
|
|
1395
|
+
bgs.append(a['bg'])
|
|
1396
|
+
if bgs:
|
|
1397
|
+
L.append('bg_rules:')
|
|
1398
|
+
for bg in bgs:
|
|
1399
|
+
users = [a['name'] for a in archetypes if a['bg'] == bg]
|
|
1400
|
+
L.append(' %s: # 用它的页型:%s' % (bg, ', '.join(users)))
|
|
1401
|
+
L.append(' text_safe: TODO安全文字区[x,y,w,h],按这张背景的主体避让后填写')
|
|
1402
|
+
L.append(' avoid: TODO禁放区列表;无禁放区写 [],有则写 [{box: [x,y,w,h], reason: "..."}]')
|
|
1403
|
+
L.append(' pairing_rule: "TODO这张背景上标题/正文/图表要避让哪些区域"')
|
|
700
1404
|
L.append('layouts:')
|
|
701
1405
|
for a in archetypes:
|
|
702
1406
|
L.append(' %s:' % a['name'])
|
|
703
|
-
|
|
1407
|
+
if a.get('role'):
|
|
1408
|
+
L.append(' role: %s' % a['role'])
|
|
704
1409
|
if a['bg']:
|
|
705
1410
|
L.append(' background: %s' % a['bg'])
|
|
706
|
-
L.append(' text_safe: TODO安全文字区[x,y,w,h],按背景主体避让后填写')
|
|
707
|
-
L.append(' avoid: TODO禁放区列表;无禁放区写 [],有则写 [{box: [x,y,w,h], reason: "..."}]')
|
|
708
|
-
L.append(' pairing_rule: "TODO说明该页型必须配这张背景时,标题/正文/图表需要避让哪些区域"')
|
|
709
1411
|
L.append(' slots:')
|
|
710
1412
|
for s in a['slots']:
|
|
711
|
-
|
|
1413
|
+
extra = ''
|
|
1414
|
+
if s.get('asset'):
|
|
1415
|
+
extra += ', asset: %s' % s['asset']
|
|
1416
|
+
for k in ('size', 'weight', 'color', 'align', 'valign'):
|
|
1417
|
+
if s.get(k) is not None:
|
|
1418
|
+
v = s[k]
|
|
1419
|
+
extra += ', %s: %s' % (k, '"%s"' % v if k == 'color' else v)
|
|
1420
|
+
L.append(' - {role: %s, box: %s, type: %s%s}'
|
|
1421
|
+
% (s['role'], s['box'], s['type'], extra))
|
|
1422
|
+
if a.get('decor'):
|
|
1423
|
+
L.append(' decor:')
|
|
1424
|
+
for dcr in a['decor']:
|
|
1425
|
+
L.append(' - {box: %s, geom: %s, css: "%s"}'
|
|
1426
|
+
% (dcr['box'], dcr['geom'], dcr['css'].replace('"', "'")))
|
|
712
1427
|
L.append(' confidence: %s' % a.get('confidence', 'medium'))
|
|
713
1428
|
write(os.path.join(ldir, 'layouts.yaml'), '\n'.join(L) + '\n')
|
|
714
1429
|
|
|
715
1430
|
|
|
716
|
-
def emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, ldir):
|
|
1431
|
+
def emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, ldir):
|
|
1432
|
+
"""design.md 正文。
|
|
1433
|
+
|
|
1434
|
+
每条规则只出现一次——同一条散在 Fast Path / Usage / Background Safety /
|
|
1435
|
+
Hard Rules 各写一遍时措辞必然漂移,消费端无法判断哪份权威。
|
|
1436
|
+
坐标、字号、色值、资产位置的权威都在 layouts.md;本文件只给色板、字体栈与纪律。
|
|
1437
|
+
"""
|
|
717
1438
|
canvas = d['canvas']['px']
|
|
718
1439
|
cover = next((a for a in assets if a['id'] == 'bg-cover'), None)
|
|
719
1440
|
logo = next((a for a in assets if a['kind'] == 'logo'), None)
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
L += ['- **配色与字体**:颜色只取 frontmatter `colors`;字体/字号只取 frontmatter `typography`;内容主题不得引入新色相。',
|
|
747
|
-
'- **版式**:优先使用下面内联版式索引;需要更多 slot 时才读 `layouts.md`;不要用 `ref/rebuild/png` 反推坐标。']
|
|
748
|
-
if archetypes:
|
|
749
|
-
L += ['', '内联版式索引(先用这里,不要为了选页型再读文件):']
|
|
750
|
-
for a in archetypes:
|
|
751
|
-
parts = ['`%s`' % a['name']]
|
|
752
|
-
if a.get('bg'):
|
|
753
|
-
parts.append('背景 `%s`' % a['bg'])
|
|
754
|
-
slots = []
|
|
755
|
-
for s in a['slots'][:4]:
|
|
756
|
-
slots.append('%s/%s %s' % (s['role'], s['type'], s['box']))
|
|
757
|
-
L.append('- %s:%s' % (' / '.join(parts), ';'.join(slots) or '按最接近用途套用'))
|
|
758
|
-
if key_assets:
|
|
759
|
-
L += ['', '关键资产:'] + ['- ' + a for a in key_assets]
|
|
760
|
-
|
|
761
|
-
L += ['', '## Overview', '',
|
|
762
|
-
'TODO: 两三句话讲清这套模板的性格与适用场景——看过联系表和页面事实之后再写,不要套话。', '',
|
|
763
|
-
'画布 %dx%d px,%d 页样张归纳出 %d 种页型;版式坐标全部放在 `layouts.md`。'
|
|
764
|
-
% (canvas[0], canvas[1], d['counts']['slides'], len(archetypes)), '',
|
|
765
|
-
'## Usage', '',
|
|
766
|
-
'搭一页 PPT 就三步:', '',
|
|
767
|
-
'1. **挑版式** —— 打开 `layouts.md`,按用途选一个 archetype,`slots[].box` 的 `[x, y, w, h]` 是 %dx%d 画布上的绝对像素,直接照搬,不要自己排版;同时遵守该 archetype 的 `text_safe` 和 `avoid`。'
|
|
768
|
-
% (canvas[0], canvas[1])]
|
|
769
|
-
if assets:
|
|
770
|
-
L.append('2. **铺资产** —— 背景和标识只用 `assets/` 里的文件,见下表;不要自造渐变或重画 logo。背景必须和版式配对,文字必须留在安全区内。')
|
|
771
|
-
else:
|
|
772
|
-
L.append('2. **铺背景** —— 本包无图片资产,背景用 `colors` 里的底色。')
|
|
773
|
-
L += ['3. **填色与字** —— 颜色只从 frontmatter 的 `colors` 取,字号只从 `typography` 取。', '']
|
|
1441
|
+
imp, webs = import_line(fonts)
|
|
1442
|
+
sidecar = '`layouts.md`'
|
|
1443
|
+
|
|
1444
|
+
L = ['## Overview', '',
|
|
1445
|
+
'TODO: 两三句话讲清这套模板的性格与适用场景——看过联系表和页面重建图之后再写。', '']
|
|
1446
|
+
L.append(('模板自带 %d 种版式,页型、坐标、字号、色值都直读自版式层。'
|
|
1447
|
+
% len(archetypes)) if (d.get('form_hint') or {}).get('form') == 3 else
|
|
1448
|
+
('%d 页样张归纳出 %d 种页型。' % (d['counts']['slides'], len(archetypes))))
|
|
1449
|
+
L += ['', '## Usage', '',
|
|
1450
|
+
'搭一页 PPT 五步,中间三步的数据都在 %s:' % sidecar, '']
|
|
1451
|
+
L += ['1. **定画布** —— 舞台按 `layouts.md` 的 `canvas` 设成 %d×%d,'
|
|
1452
|
+
'别套用默认尺寸:源模板的长宽比不一定是 16:9,套错了整页坐标全偏。'
|
|
1453
|
+
'舞台尺寸改不了时,整体等比缩放 `min(舞台宽/%d, 舞台高/%d)` 后居中留白——'
|
|
1454
|
+
'逐轴拉伸会把圆压成椭圆、把字挤扁。' % (canvas[0], canvas[1], canvas[0], canvas[1]),
|
|
1455
|
+
'2. **挑页型** —— 在 %s 里按用途选一个 archetype(清单见下面 Layouts 段)。'
|
|
1456
|
+
'页数多于页型时,挑最接近的一个原样套用它的 slot,多出来的槽删掉。' % sidecar,
|
|
1457
|
+
'3. **按 slot 落元素** —— 每个 slot 渲染成一个绝对定位元素:`box` 是 '
|
|
1458
|
+
'`[x, y, w, h]`(%dx%d 画布上的绝对像素),字号取 slot 的 `size`,'
|
|
1459
|
+
'字重取 `weight`,颜色取 `color`,对齐取 `align` / `valign`。'
|
|
1460
|
+
'带 `asset` 的 slot 是图片元素(logo、角标),把该资产放在它自己的 `box` 里;'
|
|
1461
|
+
'这个页型没有 `asset` 槽,这一页就不出现该资产。' % (canvas[0], canvas[1]),
|
|
1462
|
+
'4. **铺装饰几何** —— 页型的 `decor` 是这一页的图形骨架(图标托底的圆、'
|
|
1463
|
+
'卡片、分隔线):每条渲染成一个绝对定位空元素,`box` 给位置,`css` 原样写进 style,'
|
|
1464
|
+
'`geom: ellipse` 另加 `border-radius: 50%`。它们压在背景之上、slot 之下,'
|
|
1465
|
+
'落在 slot 上的图标正是靠它们托住。',
|
|
1466
|
+
'5. **配色与字体** —— 色板见下面 Colors 段,字体栈与 `@import` 见 Typography 段。']
|
|
774
1467
|
if assets:
|
|
775
|
-
L += ['
|
|
776
|
-
|
|
777
|
-
'
|
|
778
|
-
L += ['
|
|
779
|
-
'
|
|
1468
|
+
L += ['', '资产文件(背景由页型的 `background` 字段指定,'
|
|
1469
|
+
'图片资产的位置由该页型 `slots` 里带 `asset` 的槽给出):', '',
|
|
1470
|
+
'{{ASSET_TABLE}}']
|
|
1471
|
+
L += ['', '文字与容器的外接矩形落在该页型 `background` 对应的 `text_safe` 内,'
|
|
1472
|
+
'避开 `avoid` 列出的区域(两者都在 %s 的 `backgrounds` 段)。内容装不下时换页型或拆页。'
|
|
1473
|
+
% sidecar, '',
|
|
780
1474
|
'## Colors', '', '| token | 值 | 用途 |', '|---|---|---|']
|
|
781
|
-
USE = {'surface': '页面与卡片主底色', 'surface-alt': '次级底色,分区/强调区块的浅底',
|
|
782
|
-
'ink': '正文与标题文字色', 'ink-muted': '次级文字色,说明与标签',
|
|
783
|
-
'primary': '主强调色:图表主序列、关键数字、行动点',
|
|
784
|
-
'accent': '副强调色,多与 primary 组成渐变',
|
|
785
|
-
'accent-2': '渐变与图表的第二落点色', 'accent-3': '渐变收尾色,用量最少',
|
|
786
|
-
'neutral': '中性弱化色:分隔线、次要标签'}
|
|
787
1475
|
for name, r in tokens:
|
|
788
|
-
L.append('| `%s` | `%s` | %s |'
|
|
789
|
-
|
|
1476
|
+
L.append('| `%s` | `%s` | %s |'
|
|
1477
|
+
% (name, r['hex'], usage_phrase(cusage.get(r['hex'].upper()))))
|
|
790
1478
|
L += ['', '## Typography', '']
|
|
791
1479
|
for f in fonts[:2]:
|
|
792
1480
|
L.append('- **%s** —— 栈 `%s`%s' % (
|
|
793
1481
|
f['names'][0], font_css(f['stack']),
|
|
794
1482
|
',源为商业/内部字体无 web 分发源,按气质降级到 %s' % f['stack'][1]
|
|
795
1483
|
if len(f['stack']) > 1 else ''))
|
|
796
|
-
L += ['', '字号轴:' + '、'.join('%s %dpx' % (k, round(v['sz_px'])) for k, v in roles.items())
|
|
1484
|
+
L += ['', '字号轴:' + '、'.join('%s %dpx' % (k, round(v['sz_px'])) for k, v in roles.items())
|
|
1485
|
+
+ '。slot 自带 `size` 时以 slot 为准;层级在轴上没有的,复用最接近的一档。', '',
|
|
797
1486
|
'字体加载(**HARD REQUIREMENT:下面这行 @import 原样写入全局样式首行,禁止替换为 '
|
|
798
1487
|
'fonts.googleapis.com 或其他域**):', '', '```', imp, '```', '',
|
|
799
|
-
'镜像只保证 wght 400
|
|
800
|
-
'系统字体 PingFang SC / Microsoft YaHei
|
|
801
|
-
'## Layouts', '',
|
|
802
|
-
'
|
|
803
|
-
|
|
804
|
-
L += ['', '## Hard Rules', '']
|
|
1488
|
+
'镜像只保证 wght 400 一档,更粗的字重由浏览器合成,字重不能作为唯一区分手段;'
|
|
1489
|
+
'系统字体 PingFang SC / Microsoft YaHei 置于栈末保底,中文场景负字距清零。', '',
|
|
1490
|
+
'## Layouts', '', '页型清单如下,每个页型的 slots、background、'
|
|
1491
|
+
'禁放区都在 %s:' % sidecar, '', '{{LAYOUT_LIST}}',
|
|
1492
|
+
'', '## Hard Rules', '']
|
|
805
1493
|
if cover:
|
|
806
|
-
L.append('-
|
|
807
|
-
% (canvas[0], canvas[1]))
|
|
1494
|
+
L.append('- 封面页铺满 `bg-cover`,整幅覆盖 %dx%d 画布。' % (canvas[0], canvas[1]))
|
|
808
1495
|
if any(a['role'] == 'content' for a in assets):
|
|
809
|
-
L.append('-
|
|
1496
|
+
L.append('- 内容页的背景由该页型的 `background` 字段指定,整幅铺满。')
|
|
810
1497
|
if logo:
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
'-
|
|
818
|
-
'-
|
|
819
|
-
'- TODO: 补 1-2 条这套模板特有的硬规则(看过联系表之后写,例如主色只许用在哪类元素)。',
|
|
1498
|
+
L.append('- `%s` 的位置来自各页型 `slots` 里 `role: logo` 的 `box`——原样使用该文件,'
|
|
1499
|
+
'保持原比例。' % logo['id'])
|
|
1500
|
+
L += ['- 坐标、字号、色值、资产位置以 %s 为准;本文件的 Colors / Typography 是可用值的清单。'
|
|
1501
|
+
% sidecar,
|
|
1502
|
+
'- 内容语义色(增长绿、下降红之类)本模板没有:用色板内颜色的深浅或透明度表达正负。',
|
|
1503
|
+
'- 本包里的数值就是普查结果,照用即可,无需重新统计颜色、字体或版式。',
|
|
1504
|
+
'- 风格包以文本形式(zip 摘要等)到手时,直接用摘要里 design.md / layouts.md 的文本。',
|
|
1505
|
+
'- TODO: 补 1-2 条这套模板特有的硬规则(看过重建图之后写,例如主色只许用在哪类元素)。',
|
|
820
1506
|
'', '## Exceptions', '']
|
|
821
1507
|
if exceptions:
|
|
822
1508
|
L += ['- ' + e for e in exceptions]
|
|
@@ -898,17 +1584,68 @@ def main(argv=None):
|
|
|
898
1584
|
ldir = os.path.join(outdir, 'l-out')
|
|
899
1585
|
os.makedirs(ldir, exist_ok=True)
|
|
900
1586
|
|
|
901
|
-
|
|
1587
|
+
all_shapes = json.load(open(os.path.join(outdir, 'ref', 'shapes.json'),
|
|
1588
|
+
encoding='utf-8'))['shapes']
|
|
1589
|
+
cusage = color_usage(all_shapes, d)
|
|
1590
|
+
tokens, rest, rows = draft_colors(d, cusage)
|
|
902
1591
|
fonts = draft_fonts(d)
|
|
903
1592
|
archetypes, pages, leftover = draft_layouts(d, outdir)
|
|
904
1593
|
cover_media = next((a['bg_raw'] for a in archetypes if a['name'] == 'cover'), None)
|
|
905
1594
|
bg_needed = {a['bg_raw'] for a in archetypes if a['bg_raw'] and a['bg_raw'].startswith('ppt/media')}
|
|
906
1595
|
bg_under = {p['no']: p['bg_media'] for p in pages}
|
|
907
|
-
assets, rejected, todos = draft_assets(d, outdir, bg_needed, cover_media, bg_under)
|
|
1596
|
+
assets, rejected, todos, alias, pool = draft_assets(d, outdir, bg_needed, cover_media, bg_under)
|
|
908
1597
|
media_to_asset = {a['src']['media']: a['id'] for a in assets}
|
|
1598
|
+
for m, w in (alias or {}).items():
|
|
1599
|
+
if w in media_to_asset:
|
|
1600
|
+
media_to_asset.setdefault(m, media_to_asset[w])
|
|
1601
|
+
|
|
1602
|
+
# 版式里那些贴在装饰容器上的小图(图标托底圆里的图标之类):不进包的话,消费端只看到
|
|
1603
|
+
# 一个空圆,只能自己编图形。它们是版式的一部分,按 icon 收进来。
|
|
1604
|
+
ICON_CAP = 12
|
|
1605
|
+
ICON_BUDGET = 3 * 1024 * 1024 # 图标是小件,占包体不该超过背景
|
|
1606
|
+
cW, cH = d['canvas']['px']
|
|
1607
|
+
icon_i, icon_bytes = 0, 0
|
|
1608
|
+
for a in archetypes:
|
|
1609
|
+
for s in a['slots']:
|
|
1610
|
+
m = s.get('media')
|
|
1611
|
+
if not m or media_to_asset.get(m) or media_to_asset.get(alias.get(m, m)):
|
|
1612
|
+
continue
|
|
1613
|
+
c = pool.get(alias.get(m, m)) or pool.get(m)
|
|
1614
|
+
if not c or not c.get('out') or icon_i >= ICON_CAP:
|
|
1615
|
+
continue
|
|
1616
|
+
if icon_bytes + (c.get('bytes') or 0) > ICON_BUDGET:
|
|
1617
|
+
continue
|
|
1618
|
+
if s['box'][2] > cW * 0.25 or s['box'][3] > cH * 0.25:
|
|
1619
|
+
continue # 不是图标,是内容配图,交给消费端自备
|
|
1620
|
+
icon_i += 1
|
|
1621
|
+
icon_bytes += c.get('bytes') or 0
|
|
1622
|
+
aid = 'icon-%d' % icon_i
|
|
1623
|
+
assets.append({'id': aid, 'kind': 'icon', 'role': None, 'src': c, 'use_full': False})
|
|
1624
|
+
media_to_asset[c['media']] = aid
|
|
1625
|
+
media_to_asset[m] = aid
|
|
1626
|
+
dropped_slots = []
|
|
909
1627
|
for a in archetypes:
|
|
910
1628
|
a['bg'] = media_to_asset.get(a['bg_raw'])
|
|
1629
|
+
# 版式自带的图片元素:映射到资产 id。映射不到时**保留槽位但不写 asset**——
|
|
1630
|
+
# 删掉整条槽,消费端看到的是一个没有图标的托底圆,和图标不进包是同一个失败模式,
|
|
1631
|
+
# 而且它连「这里本来有东西」都不知道。
|
|
1632
|
+
keep = []
|
|
1633
|
+
for s in a['slots']:
|
|
1634
|
+
if s.get('media'):
|
|
1635
|
+
aid = media_to_asset.get(s['media'])
|
|
1636
|
+
if not aid:
|
|
1637
|
+
s['role'] = 'icon'
|
|
1638
|
+
s.pop('media', None)
|
|
1639
|
+
dropped_slots.append((a['name'], s['box']))
|
|
1640
|
+
keep.append(s)
|
|
1641
|
+
continue
|
|
1642
|
+
s['asset'] = aid
|
|
1643
|
+
# role 跟着资产走:图标槽写成 logo 会让消费端把它当品牌标识,每页都摆一个
|
|
1644
|
+
s['role'] = next((x['kind'] for x in assets if x['id'] == aid), s['role'])
|
|
1645
|
+
keep.append(s)
|
|
1646
|
+
a['slots'] = keep
|
|
911
1647
|
roles = draft_scale(d, archetypes)
|
|
1648
|
+
slot_added = cover_slot_colors(tokens, archetypes, rows, cusage)
|
|
912
1649
|
cands = sorted([c for c in [a['src'] for a in assets]] +
|
|
913
1650
|
[c for c, _ in rejected], key=lambda c: (-c['n'], c['file']))
|
|
914
1651
|
sheet = contact_sheet(outdir, cands, os.path.join(ldir, 'contact-sheet.png'))
|
|
@@ -919,10 +1656,33 @@ def main(argv=None):
|
|
|
919
1656
|
for c, why in rejected:
|
|
920
1657
|
if '近全透明' in why:
|
|
921
1658
|
gaps.append('母版/版式里的 %s 是%s,不是设计资产,任何情况下不要当背景用。' % (c['file'], why))
|
|
922
|
-
|
|
923
|
-
|
|
1659
|
+
if dropped_slots:
|
|
1660
|
+
gaps.append('这些图标槽的源图没有随包分发(超出图标配额或不适合进包):%s。'
|
|
1661
|
+
'槽位保留了坐标,渲染时留空或用中性占位,不要自造图形去填。'
|
|
1662
|
+
% '、'.join('%s %s' % (n, b) for n, b in dropped_slots[:8]))
|
|
1663
|
+
# 「没命中映射表」不等于「装不上」:降级目标本身(Noto Sans SC 之类)和 Office 出厂体
|
|
1664
|
+
# 都不在 match 列里,但它们本来就可用。真正危险的是**既没命中、又不是已知可用字体**的
|
|
1665
|
+
# 那种——design.md 的字体栈里留着一个消费端装不上的商业字体名,且没有任何降级说明。
|
|
1666
|
+
web_ok = {norm(x) for fam in parse_fallback_table() for x in fam['fallback']}
|
|
1667
|
+
web_ok |= {norm(x.strip().strip('"')) for x in SYS_FALLBACK.split(',')}
|
|
1668
|
+
for f in fonts:
|
|
1669
|
+
if f.get('mapped'):
|
|
924
1670
|
gaps.append('源字体 %s 无 web 授权源,已按 font-fallback 表降级到 %s;字形细节与原稿有差异。'
|
|
925
1671
|
% (f['names'][0], f['stack'][1]))
|
|
1672
|
+
elif norm(f['names'][0]) in OFFICE_DEFAULT_FONTS_NORM:
|
|
1673
|
+
gaps.append('%s 是 Office 出厂字体,多半是模板里没清干净的残留而非设计选型;'
|
|
1674
|
+
'按正文/标题的实际气质挑替代体,不要照抄它。' % f['names'][0])
|
|
1675
|
+
elif norm(f['names'][0]) not in web_ok:
|
|
1676
|
+
gaps.append('源字体 %s 不在 font-fallback 表里,字体栈只有原名,消费端很可能装不上;'
|
|
1677
|
+
'按气质挑一个有 web 分发源的近似体补进栈,不要照抄原名。' % f['names'][0])
|
|
1678
|
+
nosize = [(a['name'], s['box']) for a in archetypes for s in a['slots']
|
|
1679
|
+
if not s.get('asset') and not s.get('size')]
|
|
1680
|
+
if nosize:
|
|
1681
|
+
gaps.append('这些文字槽在源文件任何层级都没有字号声明(都不是占位符,是普通文本框,'
|
|
1682
|
+
'继承源是 presentation.xml 的 defaultTextStyle,本抽取按约定不解继承链):'
|
|
1683
|
+
'%s。用 typography 里最接近的档位,不要自造新档。'
|
|
1684
|
+
% '、'.join('%s %s' % (n, b) for n, b in nosize[:6]))
|
|
1685
|
+
|
|
926
1686
|
if leftover:
|
|
927
1687
|
exceptions.append('源 deck 第 %s 页是单页孤例,没有归纳成 archetype;需要类似构图时按最接近的页型改。'
|
|
928
1688
|
% '、'.join(map(str, leftover)))
|
|
@@ -930,7 +1690,7 @@ def main(argv=None):
|
|
|
930
1690
|
emit_manifest(d, assets, ldir)
|
|
931
1691
|
emit_frontmatter(d, tokens, fonts, roles, anchors, gaps, ldir)
|
|
932
1692
|
emit_layouts(archetypes, ldir)
|
|
933
|
-
emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, ldir)
|
|
1693
|
+
emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, ldir)
|
|
934
1694
|
emit_brief(d, (tokens, rest, fonts, roles, assets, rejected, todos, archetypes, cands, sheet,
|
|
935
1695
|
leftover, lsheet), ldir)
|
|
936
1696
|
|