@lark-apaas/coding-steering 0.1.31 → 0.1.32-beta.0
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 +112 -0
- package/steering/design-html/skills/pptx-style-extract/font-fallback.yaml +129 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/census.py +955 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/check_v2.py +907 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/draft.py +945 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/export_consumer_md.py +75 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/export_consumer_zip.py +175 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/extract.py +765 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/ooxml.py +699 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/package.py +1120 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/parts.py +461 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/query.py +562 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/render_pages.py +679 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/verify_font.py +68 -0
- package/steering/design-html/skills/pptx-style-extract/v2-format-spec.md +193 -0
|
@@ -0,0 +1,955 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""S5 image census / S6 colour+font frequency / S7 layout inventory / S8 guides /
|
|
3
|
+
S13 theme topology, plus the derived text-scale, spacing, radii and effects censuses."""
|
|
4
|
+
import re
|
|
5
|
+
from collections import Counter, OrderedDict, defaultdict
|
|
6
|
+
|
|
7
|
+
from ooxml import (NS, alias_group, family_of, local, luminance, raw_token, read_color,
|
|
8
|
+
resolve_color)
|
|
9
|
+
|
|
10
|
+
# --- S5 epsilon split (方案 v0.2 §1 S5): position/repeat matching uses ±0.5% of the
|
|
11
|
+
# 1920px canvas; fullscreen detection has its own independent threshold.
|
|
12
|
+
EPS_PX = 0.005 * 1920 # 9.6 px
|
|
13
|
+
FULLSCREEN_MIN_PCT = 95.0 # 上不封顶(尺寸维度,兼容旧口径)
|
|
14
|
+
FULLSCREEN_COVERAGE = 0.95 # 画布覆盖率判据(2026-08-05 用户 case:溢出/略未贴边都算背景,但偏移出画布的大图不算)
|
|
15
|
+
REPEAT_MIN = 2
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _canvas_coverage(box, cw=1920.0, ch=1080.0):
|
|
19
|
+
"""图片与画布交集面积 / 画布面积。溢出(bleed)交集封顶于画布,天然 ≤1。"""
|
|
20
|
+
if not box:
|
|
21
|
+
return 0.0
|
|
22
|
+
x, y, w, h = box.get('x', 0), box.get('y', 0), box.get('w', 0), box.get('h', 0)
|
|
23
|
+
iw = max(0.0, min(x + w, cw) - max(x, 0.0))
|
|
24
|
+
ih = max(0.0, min(y + h, ch) - max(y, 0.0))
|
|
25
|
+
return (iw * ih) / (cw * ch)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ------------------------------------------------------------- S5 image census
|
|
29
|
+
def _key(box):
|
|
30
|
+
return (box['x'], box['y'], box['w'], box['h'])
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _close(a, b, eps=EPS_PX):
|
|
34
|
+
return all(abs(a[i] - b[i]) <= eps for i in range(4))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _cluster(occs, eps=EPS_PX):
|
|
38
|
+
"""Greedy epsilon clustering over occurrence boxes."""
|
|
39
|
+
clusters = []
|
|
40
|
+
for o in occs:
|
|
41
|
+
k = _key(o['box'])
|
|
42
|
+
for c in clusters:
|
|
43
|
+
if _close(k, c['rep']):
|
|
44
|
+
c['occs'].append(o)
|
|
45
|
+
break
|
|
46
|
+
else:
|
|
47
|
+
clusters.append({'rep': k, 'occs': [o]})
|
|
48
|
+
return clusters
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def image_census(shape_recs, bg_images, units):
|
|
52
|
+
"""Per-image (media, box, crop) occurrence inventory with the three logo signals."""
|
|
53
|
+
occ = defaultdict(list)
|
|
54
|
+
for r in shape_recs:
|
|
55
|
+
media = r.get('media')
|
|
56
|
+
if not media or not r.get('box'):
|
|
57
|
+
continue
|
|
58
|
+
occ[media].append({
|
|
59
|
+
'part': r['part'], 'layer': r['layer'], 'via': 'pic',
|
|
60
|
+
'box': r['box'], 'box_emu': r.get('box_emu'),
|
|
61
|
+
'crop': r.get('crop'), 'placement': r.get('placement'),
|
|
62
|
+
'w_pct': r.get('w_pct'), 'h_pct': r.get('h_pct'),
|
|
63
|
+
'in_group': bool(r.get('group_path')), 'svg': r.get('media_svg'),
|
|
64
|
+
})
|
|
65
|
+
for r in shape_recs:
|
|
66
|
+
f = r.get('fill') or {}
|
|
67
|
+
if f.get('type') == 'image' and f.get('media') and r.get('box'):
|
|
68
|
+
occ[f['media']].append({
|
|
69
|
+
'part': r['part'], 'layer': r['layer'], 'via': 'shape-fill',
|
|
70
|
+
'box': r['box'], 'box_emu': r.get('box_emu'), 'crop': f.get('crop'),
|
|
71
|
+
'placement': r.get('placement'), 'w_pct': r.get('w_pct'),
|
|
72
|
+
'h_pct': r.get('h_pct'), 'in_group': bool(r.get('group_path')),
|
|
73
|
+
})
|
|
74
|
+
for b in bg_images:
|
|
75
|
+
occ[b['media']].append(b)
|
|
76
|
+
|
|
77
|
+
images = []
|
|
78
|
+
for media, occs in occ.items():
|
|
79
|
+
exact = Counter()
|
|
80
|
+
for o in occs:
|
|
81
|
+
exact[_key(o['box'])] += 1
|
|
82
|
+
clusters = []
|
|
83
|
+
for c in _cluster(occs):
|
|
84
|
+
boxes = [_key(o['box']) for o in c['occs']]
|
|
85
|
+
rep = Counter(boxes).most_common(1)[0][0]
|
|
86
|
+
rep_occ = next(o for o in c['occs'] if _key(o['box']) == rep)
|
|
87
|
+
clusters.append({
|
|
88
|
+
'box': {'x': rep[0], 'y': rep[1], 'w': rep[2], 'h': rep[3]},
|
|
89
|
+
'box_emu': rep_occ.get('box_emu'),
|
|
90
|
+
'count': len(c['occs']),
|
|
91
|
+
'exact_count': exact[rep],
|
|
92
|
+
'exact_variants': len(set(boxes)),
|
|
93
|
+
'parts': sorted({o['part'] for o in c['occs']}),
|
|
94
|
+
'layers': sorted({o['layer'] for o in c['occs']}),
|
|
95
|
+
'crops': sorted({_crop_sig(o.get('crop')) for o in c['occs']}),
|
|
96
|
+
'w_pct': rep_occ.get('w_pct'), 'h_pct': rep_occ.get('h_pct'),
|
|
97
|
+
'placement': rep_occ.get('placement'),
|
|
98
|
+
'bleed': rep_occ.get('placement') == 'bleed',
|
|
99
|
+
'in_group': any(o.get('in_group') for o in c['occs']),
|
|
100
|
+
})
|
|
101
|
+
clusters.sort(key=lambda c: -c['count'])
|
|
102
|
+
fs = [o for o in occs if _canvas_coverage(o.get('box')) >= FULLSCREEN_COVERAGE]
|
|
103
|
+
fs_clusters = [c for c in clusters if _canvas_coverage(c.get('box')) >= FULLSCREEN_COVERAGE]
|
|
104
|
+
crop_sigs = {_crop_sig(o.get('crop')) for o in occs} - {''}
|
|
105
|
+
images.append({
|
|
106
|
+
'media': media,
|
|
107
|
+
'n': len(occs),
|
|
108
|
+
'boxes': clusters,
|
|
109
|
+
'exact_boxes': [{'box': {'x': k[0], 'y': k[1], 'w': k[2], 'h': k[3]}, 'count': n}
|
|
110
|
+
for k, n in exact.most_common()],
|
|
111
|
+
'fullscreen': bool(fs),
|
|
112
|
+
'fullscreen_n': len(fs),
|
|
113
|
+
'fullscreen_top_cluster_n': max((c['exact_count'] for c in fs_clusters), default=0),
|
|
114
|
+
'repeat_fixed': [c['box'] for c in clusters if c['count'] >= REPEAT_MIN],
|
|
115
|
+
'max_w_pct': max((o.get('w_pct') or 0) for o in occs),
|
|
116
|
+
'bleed': any(o.get('placement') == 'bleed' for o in occs),
|
|
117
|
+
'crop_variants': sorted(crop_sigs),
|
|
118
|
+
'stitch_candidate': len(crop_sigs) > 1,
|
|
119
|
+
'svg_companion': next((o.get('svg') for o in occs if o.get('svg')), None),
|
|
120
|
+
})
|
|
121
|
+
images.sort(key=lambda i: (-i['n'], i['media']))
|
|
122
|
+
|
|
123
|
+
# variant_group: different media occupying the same position (many-to-many).
|
|
124
|
+
pos = []
|
|
125
|
+
for img in images:
|
|
126
|
+
for c in img['boxes']:
|
|
127
|
+
k = _key(c['box'])
|
|
128
|
+
for p in pos:
|
|
129
|
+
if _close(k, p['rep']):
|
|
130
|
+
p['members'].append({'media': img['media'], 'count': c['count']})
|
|
131
|
+
break
|
|
132
|
+
else:
|
|
133
|
+
pos.append({'rep': k, 'box': dict(c['box']),
|
|
134
|
+
'members': [{'media': img['media'], 'count': c['count']}]})
|
|
135
|
+
groups = []
|
|
136
|
+
for i, p in enumerate(pos):
|
|
137
|
+
if len({m['media'] for m in p['members']}) < 2:
|
|
138
|
+
continue
|
|
139
|
+
gid = 'vg%d' % (len(groups) + 1)
|
|
140
|
+
groups.append({'id': gid, 'box': p['box'],
|
|
141
|
+
'members': sorted(p['members'], key=lambda m: -m['count'])})
|
|
142
|
+
by_media = defaultdict(list)
|
|
143
|
+
for g in groups:
|
|
144
|
+
for m in g['members']:
|
|
145
|
+
by_media[m['media']].append(g['id'])
|
|
146
|
+
for img in images:
|
|
147
|
+
img['variant_group'] = by_media.get(img['media'], [])
|
|
148
|
+
return images, groups
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _crop_sig(crop):
|
|
152
|
+
if not crop:
|
|
153
|
+
return ''
|
|
154
|
+
return ','.join('%s=%s' % (k, crop[k]) for k in sorted(crop))
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# ------------------------------------------ S5b media content clustering (素材聚类)
|
|
158
|
+
# Two levels: sha256 byte identity, then perceptual identity. The perceptual level
|
|
159
|
+
# is a dHash *prefilter* followed by a pixel confirmation, because dHash alone does
|
|
160
|
+
# not separate these samples: measured pairs sit at distance 0 with 29.5 mean
|
|
161
|
+
# channel difference (pipelinepro image-1-1 vs image-4-1, genuinely different) and
|
|
162
|
+
# at distance 7 with 0.54 (image-2-1 vs image-4-1, genuinely the same). No single
|
|
163
|
+
# hamming threshold splits that, so the hash only narrows the candidate set and the
|
|
164
|
+
# thumbnail difference decides. Calibration table is in reports/agent-extract-round2.md.
|
|
165
|
+
DHASH_PREFILTER_MAX = 10 # hamming distance over the 64-bit dHash
|
|
166
|
+
PIXDIFF_MAX = 5.0 # mean per-channel |Δ| over the 64x64 thumbnail
|
|
167
|
+
THUMB_PX = 64
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _composite_on_white(im):
|
|
171
|
+
"""Flatten alpha the way a slide renders it, so transparent padding cannot
|
|
172
|
+
masquerade as image content."""
|
|
173
|
+
from PIL import Image
|
|
174
|
+
if im.mode in ('RGBA', 'LA', 'P'):
|
|
175
|
+
im = im.convert('RGBA')
|
|
176
|
+
return Image.alpha_composite(Image.new('RGBA', im.size, (255, 255, 255, 255)),
|
|
177
|
+
im).convert('RGB')
|
|
178
|
+
return im.convert('RGB')
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _dhash(rgb):
|
|
182
|
+
"""9x8 grayscale row-wise gradient hash -> 64 bits."""
|
|
183
|
+
from PIL import Image
|
|
184
|
+
g = rgb.convert('L').resize((9, 8), Image.LANCZOS)
|
|
185
|
+
px = list(g.getdata())
|
|
186
|
+
bits = 0
|
|
187
|
+
for r in range(8):
|
|
188
|
+
for c in range(8):
|
|
189
|
+
bits = (bits << 1) | (1 if px[r * 9 + c] > px[r * 9 + c + 1] else 0)
|
|
190
|
+
return bits
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def media_fingerprints(pkg, medias, pillow_ok):
|
|
194
|
+
"""sha256 (always) + dHash and thumbnails (Pillow only), per media part."""
|
|
195
|
+
import hashlib
|
|
196
|
+
out = {}
|
|
197
|
+
for m in sorted(set(medias)):
|
|
198
|
+
if not m or m not in pkg.names:
|
|
199
|
+
continue
|
|
200
|
+
raw = pkg.zip.read(m)
|
|
201
|
+
rec = {'sha256': hashlib.sha256(raw).hexdigest(), 'bytes': len(raw)}
|
|
202
|
+
if pillow_ok and not m.lower().endswith('.svg'):
|
|
203
|
+
try:
|
|
204
|
+
import io
|
|
205
|
+
|
|
206
|
+
from PIL import Image
|
|
207
|
+
im = Image.open(io.BytesIO(raw))
|
|
208
|
+
im.load()
|
|
209
|
+
rec['px'] = list(im.size)
|
|
210
|
+
rgb = _composite_on_white(im)
|
|
211
|
+
rec['phash'] = '%016x' % _dhash(rgb)
|
|
212
|
+
rec['_thumb_rgb'] = rgb.resize((THUMB_PX, THUMB_PX), Image.LANCZOS)
|
|
213
|
+
rec['_thumb_rgba'] = im.convert('RGBA').resize(
|
|
214
|
+
(THUMB_PX, THUMB_PX), Image.LANCZOS)
|
|
215
|
+
except Exception as exc:
|
|
216
|
+
rec['fingerprint_error'] = '%s: %s' % (exc.__class__.__name__, exc)
|
|
217
|
+
out[m] = rec
|
|
218
|
+
return out
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _pixdiff(a, b):
|
|
222
|
+
from PIL import ImageChops, ImageStat
|
|
223
|
+
st = ImageStat.Stat(ImageChops.difference(a, b))
|
|
224
|
+
return sum(st.mean) / 3.0
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def content_clusters(images, fps):
|
|
228
|
+
"""Assign every image row a `content_id`, and merge same-content media.
|
|
229
|
+
|
|
230
|
+
Returns the `media_clusters` table. Rows are mutated in place with
|
|
231
|
+
`content_id` / `phash` only — every pre-existing field is left untouched,
|
|
232
|
+
because verify_gates reads them.
|
|
233
|
+
"""
|
|
234
|
+
medias = [i['media'] for i in images]
|
|
235
|
+
parent = {m: m for m in medias}
|
|
236
|
+
|
|
237
|
+
def find(x):
|
|
238
|
+
while parent[x] != x:
|
|
239
|
+
parent[x] = parent[parent[x]]
|
|
240
|
+
x = parent[x]
|
|
241
|
+
return x
|
|
242
|
+
|
|
243
|
+
def union(a, b):
|
|
244
|
+
ra, rb = find(a), find(b)
|
|
245
|
+
if ra != rb:
|
|
246
|
+
parent[max(ra, rb)] = min(ra, rb)
|
|
247
|
+
|
|
248
|
+
evidence = []
|
|
249
|
+
for i, a in enumerate(medias):
|
|
250
|
+
fa = fps.get(a) or {}
|
|
251
|
+
for b in medias[i + 1:]:
|
|
252
|
+
fb = fps.get(b) or {}
|
|
253
|
+
if fa.get('sha256') and fa['sha256'] == fb.get('sha256'):
|
|
254
|
+
union(a, b)
|
|
255
|
+
evidence.append({'a': a, 'b': b, 'level': 'sha256', 'dhash_distance': 0,
|
|
256
|
+
'pixel_diff': 0.0})
|
|
257
|
+
continue
|
|
258
|
+
if not fa.get('phash') or not fb.get('phash'):
|
|
259
|
+
continue
|
|
260
|
+
d = bin(int(fa['phash'], 16) ^ int(fb['phash'], 16)).count('1')
|
|
261
|
+
if d > DHASH_PREFILTER_MAX:
|
|
262
|
+
continue
|
|
263
|
+
try:
|
|
264
|
+
pd = _pixdiff(fa['_thumb_rgb'], fb['_thumb_rgb'])
|
|
265
|
+
except Exception:
|
|
266
|
+
continue
|
|
267
|
+
if pd <= PIXDIFF_MAX:
|
|
268
|
+
union(a, b)
|
|
269
|
+
evidence.append({'a': a, 'b': b, 'level': 'perceptual',
|
|
270
|
+
'dhash_distance': d, 'pixel_diff': round(pd, 2)})
|
|
271
|
+
else:
|
|
272
|
+
evidence.append({'a': a, 'b': b, 'level': 'rejected',
|
|
273
|
+
'dhash_distance': d, 'pixel_diff': round(pd, 2)})
|
|
274
|
+
|
|
275
|
+
by_root = defaultdict(list)
|
|
276
|
+
for m in medias:
|
|
277
|
+
by_root[find(m)].append(m)
|
|
278
|
+
ids, clusters = {}, []
|
|
279
|
+
by_media = {i['media']: i for i in images}
|
|
280
|
+
for root in sorted(by_root, key=lambda r: (-len(by_root[r]), r)):
|
|
281
|
+
members = sorted(by_root[root])
|
|
282
|
+
cid = 'c%d' % (len(clusters) + 1)
|
|
283
|
+
for m in members:
|
|
284
|
+
ids[m] = cid
|
|
285
|
+
# Cluster-level repeat detection: the whole point of the clustering is
|
|
286
|
+
# that N pasted copies of one asset must count as N occurrences of one
|
|
287
|
+
# asset, so the epsilon merge runs across member media, not within one.
|
|
288
|
+
merged = []
|
|
289
|
+
for m in members:
|
|
290
|
+
for c in by_media[m]['boxes']:
|
|
291
|
+
k = _key(c['box'])
|
|
292
|
+
for mc in merged:
|
|
293
|
+
if _close(k, mc['rep']):
|
|
294
|
+
mc['count'] += c['count']
|
|
295
|
+
mc['media'].add(m)
|
|
296
|
+
break
|
|
297
|
+
else:
|
|
298
|
+
merged.append({'rep': k, 'box': dict(c['box']), 'count': c['count'],
|
|
299
|
+
'media': {m}})
|
|
300
|
+
merged.sort(key=lambda c: -c['count'])
|
|
301
|
+
clusters.append({
|
|
302
|
+
'content_id': cid,
|
|
303
|
+
'members': members,
|
|
304
|
+
'member_n': len(members),
|
|
305
|
+
'sha256_identical': len({(fps.get(m) or {}).get('sha256') for m in members}) == 1,
|
|
306
|
+
'phash': (fps.get(members[0]) or {}).get('phash'),
|
|
307
|
+
'n': sum(by_media[m]['n'] for m in members),
|
|
308
|
+
'boxes': [{'box': c['box'], 'count': c['count'],
|
|
309
|
+
'media': sorted(c['media']), 'cross_media': len(c['media']) > 1}
|
|
310
|
+
for c in merged],
|
|
311
|
+
'repeat_fixed': [c['box'] for c in merged if c['count'] >= REPEAT_MIN],
|
|
312
|
+
'repeat_fixed_cross_media': [c['box'] for c in merged
|
|
313
|
+
if c['count'] >= REPEAT_MIN and len(c['media']) > 1],
|
|
314
|
+
'fullscreen': any(by_media[m]['fullscreen'] for m in members),
|
|
315
|
+
})
|
|
316
|
+
for img in images:
|
|
317
|
+
img['content_id'] = ids.get(img['media'])
|
|
318
|
+
img['phash'] = (fps.get(img['media']) or {}).get('phash')
|
|
319
|
+
return clusters, evidence
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
# ------------------------------------------------------------ S14 image palette
|
|
323
|
+
PALETTE_TOP_N = 5
|
|
324
|
+
PALETTE_BUCKET_SHIFT = 4 # 16 levels per channel
|
|
325
|
+
PALETTE_ALPHA_MIN = 128
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def image_palette(images, fps, exported):
|
|
329
|
+
"""Pixel-level dominant colours + whole-image luminance for exported assets.
|
|
330
|
+
|
|
331
|
+
Sampled from the *original* bytes in the package, not the webp the transcoder
|
|
332
|
+
writes, so the palette does not inherit quality-ladder artefacts. Pixels below
|
|
333
|
+
PALETTE_ALPHA_MIN are dropped: a logo on a transparent bed would otherwise
|
|
334
|
+
report its padding as the dominant colour.
|
|
335
|
+
"""
|
|
336
|
+
from ooxml import _lin
|
|
337
|
+
lut = [_lin(i) for i in range(256)]
|
|
338
|
+
for img in images:
|
|
339
|
+
if img['media'] not in exported:
|
|
340
|
+
continue
|
|
341
|
+
f = fps.get(img['media']) or {}
|
|
342
|
+
thumb = f.get('_thumb_rgba')
|
|
343
|
+
if thumb is None:
|
|
344
|
+
continue
|
|
345
|
+
buckets, lum, n = defaultdict(lambda: [0, 0, 0, 0]), 0.0, 0
|
|
346
|
+
for r, g, b, a in thumb.getdata():
|
|
347
|
+
if a < PALETTE_ALPHA_MIN:
|
|
348
|
+
continue
|
|
349
|
+
n += 1
|
|
350
|
+
lum += 0.2126 * lut[r] + 0.7152 * lut[g] + 0.0722 * lut[b]
|
|
351
|
+
k = (r >> PALETTE_BUCKET_SHIFT, g >> PALETTE_BUCKET_SHIFT,
|
|
352
|
+
b >> PALETTE_BUCKET_SHIFT)
|
|
353
|
+
acc = buckets[k]
|
|
354
|
+
acc[0] += 1
|
|
355
|
+
acc[1] += r
|
|
356
|
+
acc[2] += g
|
|
357
|
+
acc[3] += b
|
|
358
|
+
if not n:
|
|
359
|
+
img['dominant_colors'] = []
|
|
360
|
+
img['luminance'] = None
|
|
361
|
+
img['palette_note'] = 'fully transparent above alpha %d' % PALETTE_ALPHA_MIN
|
|
362
|
+
continue
|
|
363
|
+
top = sorted(buckets.values(), key=lambda v: -v[0])[:PALETTE_TOP_N]
|
|
364
|
+
img['dominant_colors'] = [
|
|
365
|
+
{'hex': '#%02X%02X%02X' % (round(c[1] / c[0]), round(c[2] / c[0]),
|
|
366
|
+
round(c[3] / c[0])),
|
|
367
|
+
'pct': round(c[0] * 100.0 / n, 2)} for c in top]
|
|
368
|
+
img['luminance'] = round(lum / n, 4)
|
|
369
|
+
img['opaque_sample_px'] = n
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
# ------------------------------------------------- S6 colour frequency (resolved)
|
|
373
|
+
# Declared XPath range: colours counted only when reachable through one of these
|
|
374
|
+
# containers. `p15:clr` -> editor, `a:buClr` / styleRef -> aux, everything else design.
|
|
375
|
+
DESIGN_VIA = ('solidFill', 'gs', 'bgRef')
|
|
376
|
+
EDITOR_VIA = ('clr',) # p15:clr (guides)
|
|
377
|
+
AUX_VIA = ('buClr', 'fillRef', 'lnRef', 'effectRef', 'fontRef')
|
|
378
|
+
COLOR_TAGS = {'srgbClr', 'schemeClr', 'sysClr', 'prstClr', 'scrgbClr', 'hslClr'}
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _scan_colors(el, ctx_stack, sink):
|
|
382
|
+
tag = local(el.tag)
|
|
383
|
+
if tag in COLOR_TAGS:
|
|
384
|
+
cls = None
|
|
385
|
+
for anc in reversed(ctx_stack):
|
|
386
|
+
if anc in EDITOR_VIA:
|
|
387
|
+
cls = 'editor'
|
|
388
|
+
break
|
|
389
|
+
if anc in AUX_VIA:
|
|
390
|
+
cls = 'aux'
|
|
391
|
+
break
|
|
392
|
+
if anc in DESIGN_VIA:
|
|
393
|
+
cls = 'design'
|
|
394
|
+
break
|
|
395
|
+
if cls:
|
|
396
|
+
sink.append((el, cls))
|
|
397
|
+
return
|
|
398
|
+
ctx_stack.append(tag)
|
|
399
|
+
for ch in el:
|
|
400
|
+
_scan_colors(ch, ctx_stack, sink)
|
|
401
|
+
ctx_stack.pop()
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def color_census(pkg, part_ctxs):
|
|
405
|
+
"""Counts keyed on the *resolved* hex/rgba (schemeClr -> clrMap -> clrScheme)."""
|
|
406
|
+
agg = OrderedDict()
|
|
407
|
+
raw_rows = []
|
|
408
|
+
for ctx in part_ctxs:
|
|
409
|
+
sink = []
|
|
410
|
+
_scan_colors(pkg.xml(ctx.part), [], sink)
|
|
411
|
+
for el, cls in sink:
|
|
412
|
+
raw = read_color(el)
|
|
413
|
+
res = resolve_color(raw, ctx.clrmap, ctx.clrscheme)
|
|
414
|
+
if not res:
|
|
415
|
+
continue
|
|
416
|
+
key = res.get('resolved') or ('UNRESOLVED:' + str(res.get('unresolved')))
|
|
417
|
+
e = agg.setdefault(key, {'resolved': key, 'hex': res.get('hex'),
|
|
418
|
+
'alpha': res.get('alpha'), 'n': 0,
|
|
419
|
+
'class': cls, 'layers': Counter(), 'raw': Counter()})
|
|
420
|
+
e['n'] += 1
|
|
421
|
+
e['layers'][ctx.layer] += 1
|
|
422
|
+
e['raw'][res['raw']] += 1
|
|
423
|
+
# design evidence wins over aux/editor if a colour appears in both roles
|
|
424
|
+
if cls == 'design':
|
|
425
|
+
e['class'] = 'design'
|
|
426
|
+
raw_rows.append({'part': ctx.part, 'layer': ctx.layer, 'class': cls,
|
|
427
|
+
'raw': res['raw'], 'resolved': key})
|
|
428
|
+
out = []
|
|
429
|
+
for e in agg.values():
|
|
430
|
+
out.append({'resolved': e['resolved'], 'hex': e['hex'], 'alpha': e['alpha'],
|
|
431
|
+
'n': e['n'], 'class': e['class'],
|
|
432
|
+
'layers': dict(e['layers']),
|
|
433
|
+
'raw': [r for r, _ in e['raw'].most_common()],
|
|
434
|
+
'raw_counts': dict(e['raw'])})
|
|
435
|
+
out.sort(key=lambda e: (-e['n'], e['resolved']))
|
|
436
|
+
return out, raw_rows
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
# ------------------------------------------------------------ S6 font clustering
|
|
440
|
+
FONT_REF_RE = re.compile(r'^\+(mj|mn)-(lt|ea|cs)$')
|
|
441
|
+
FONT_REF_KIND = {'mj': 'majorFont', 'mn': 'minorFont'}
|
|
442
|
+
FONT_REF_SLOT = {'lt': 'latin', 'ea': 'ea', 'cs': 'cs'}
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def font_scheme_by_part(pkg, graph, theme_of_master):
|
|
446
|
+
"""part -> 该 part 所在母版链绑定 theme 的 fontScheme。
|
|
447
|
+
|
|
448
|
+
`+mj-lt` 之类占位符要解析成实名就得知道「这个形状属于哪条母版链」,
|
|
449
|
+
与 schemeClr 走 clrMap→clrScheme 是同一条依赖。
|
|
450
|
+
"""
|
|
451
|
+
of_master = {mp: (theme_of_master.get(mp) or {}).get('fontScheme') or {}
|
|
452
|
+
for mp in graph['master_order']}
|
|
453
|
+
out = dict(of_master)
|
|
454
|
+
for lp in pkg.layouts:
|
|
455
|
+
out[lp] = of_master.get(graph['master_of_layout'].get(lp), {})
|
|
456
|
+
for sp in pkg.slides:
|
|
457
|
+
layout = graph['layout_of_slide'].get(sp)
|
|
458
|
+
out[sp] = of_master.get(graph['master_of_layout'].get(layout), {})
|
|
459
|
+
return out
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def resolve_font_ref(raw, scheme):
|
|
463
|
+
"""`+mj-lt` → fontScheme 实名。返回 (face, ref, status)。
|
|
464
|
+
|
|
465
|
+
与颜色一致:以解析后的实名计数。三种结局要分开,否则第三种会把非字体塞进字体表:
|
|
466
|
+
plain —— 不是占位符
|
|
467
|
+
resolved —— 解析到实名
|
|
468
|
+
empty —— 主题里该槽位显式为空串(`<a:cs typeface=""/>`,合法的「不指定」)
|
|
469
|
+
unresolved —— 压根没有可用 fontScheme / 无该槽位,才保留占位符并标注
|
|
470
|
+
"""
|
|
471
|
+
m = FONT_REF_RE.match(raw or '')
|
|
472
|
+
if not m:
|
|
473
|
+
return raw, None, 'plain'
|
|
474
|
+
kind = (scheme or {}).get(FONT_REF_KIND[m.group(1)])
|
|
475
|
+
slot = FONT_REF_SLOT[m.group(2)]
|
|
476
|
+
if not isinstance(kind, dict) or slot not in kind:
|
|
477
|
+
return None, raw, 'unresolved'
|
|
478
|
+
face = kind[slot]
|
|
479
|
+
return (face, raw, 'resolved') if face else (None, raw, 'empty')
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def font_census(shape_recs, txstyles_by_master, themes, scheme_by_part=None):
|
|
483
|
+
fams = {}
|
|
484
|
+
theme_faces = set()
|
|
485
|
+
for t in themes:
|
|
486
|
+
for kind in ('majorFont', 'minorFont'):
|
|
487
|
+
for face in (t.get('fontScheme', {}).get(kind) or {}).values():
|
|
488
|
+
if face:
|
|
489
|
+
theme_faces.add(face)
|
|
490
|
+
|
|
491
|
+
def add(raw, layer, slot, source, scheme, bold=False):
|
|
492
|
+
if not raw:
|
|
493
|
+
return
|
|
494
|
+
face, ref, status = resolve_font_ref(raw, scheme)
|
|
495
|
+
if status == 'empty':
|
|
496
|
+
return # 主题显式不指定该槽位,等同于没有声明过
|
|
497
|
+
if status == 'unresolved':
|
|
498
|
+
# Keep the placeholder as its own family rather than dropping it, so
|
|
499
|
+
# a broken theme binding is visible instead of silent.
|
|
500
|
+
face = ref
|
|
501
|
+
family, weight, italic = family_of(face)
|
|
502
|
+
e = fams.setdefault(family, {'family': family, 'n': 0, 'rendered': 0,
|
|
503
|
+
'variants': OrderedDict(), 'positions': Counter(),
|
|
504
|
+
'slots': Counter(), 'sources': Counter(),
|
|
505
|
+
'theme_refs': Counter(), 'unresolved_ref': False,
|
|
506
|
+
'italic': False, 'bold_runs': 0})
|
|
507
|
+
e['n'] += 1
|
|
508
|
+
if source != 'pPr_empty':
|
|
509
|
+
e['rendered'] += 1
|
|
510
|
+
e['positions'][layer] += 1
|
|
511
|
+
e['slots'][slot] += 1
|
|
512
|
+
e['sources'][source] += 1
|
|
513
|
+
e['italic'] = e['italic'] or italic
|
|
514
|
+
if ref:
|
|
515
|
+
e['theme_refs'][ref] += 1
|
|
516
|
+
if face == ref:
|
|
517
|
+
e['unresolved_ref'] = True
|
|
518
|
+
if bold:
|
|
519
|
+
e['bold_runs'] += 1
|
|
520
|
+
# weight comes from the font *name* only — it is the sole 字重 source; a b="1"
|
|
521
|
+
# flag is a separate signal and is counted apart so L4 can tell them apart.
|
|
522
|
+
v = e['variants'].setdefault(face, {'raw': face, 'weight': weight, 'n': 0,
|
|
523
|
+
'bold_runs': 0, 'truncated': len(face) == 31})
|
|
524
|
+
v['n'] += 1
|
|
525
|
+
if bold:
|
|
526
|
+
v['bold_runs'] += 1
|
|
527
|
+
|
|
528
|
+
def visit_rpr(d, layer, source, scheme, skip=()):
|
|
529
|
+
bold = bool(d.get('bold'))
|
|
530
|
+
for slot in ('latin', 'ea', 'cs'):
|
|
531
|
+
if d.get(slot) and slot not in skip:
|
|
532
|
+
add(d[slot], layer, slot, source, scheme, bold)
|
|
533
|
+
|
|
534
|
+
for r in shape_recs:
|
|
535
|
+
layer = r['layer']
|
|
536
|
+
scheme = (scheme_by_part or {}).get(r['part'], {})
|
|
537
|
+
text = r.get('text') or {}
|
|
538
|
+
for lvl in (text.get('lstStyle') or {}).values():
|
|
539
|
+
visit_rpr(lvl, layer, 'lstStyle', scheme)
|
|
540
|
+
for p in text.get('paragraphs', []):
|
|
541
|
+
runs = [run for run in p.get('runs', []) if not run.get('empty_para')]
|
|
542
|
+
for run in p.get('runs', []):
|
|
543
|
+
visit_rpr(run, layer, 'run', scheme)
|
|
544
|
+
# a:p/a:pPr/a:defRPr supplies this paragraph's run defaults, per slot:
|
|
545
|
+
# a run declaring `latin` does not suppress the paragraph's `ea`.
|
|
546
|
+
# A paragraph with no runs at all (only <a:endParaRPr/>) declares a
|
|
547
|
+
# default that renders no glyph, so it is counted under its own source
|
|
548
|
+
# and kept out of `rendered_n` — otherwise a face backing zero visible
|
|
549
|
+
# text can outrank the deck's actual typeface (volcano: 975 such).
|
|
550
|
+
dr = p.get('defRPr') or {}
|
|
551
|
+
if dr:
|
|
552
|
+
covered = {slot for slot in ('latin', 'ea', 'cs')
|
|
553
|
+
if any(run.get(slot) for run in runs)}
|
|
554
|
+
visit_rpr(dr, layer, 'pPr' if runs else 'pPr_empty', scheme, skip=covered)
|
|
555
|
+
for master, ts in (txstyles_by_master or {}).items():
|
|
556
|
+
scheme = (scheme_by_part or {}).get(master, {})
|
|
557
|
+
for lvls in (ts or {}).values():
|
|
558
|
+
for lvl in lvls.values():
|
|
559
|
+
visit_rpr(lvl, 'master', 'txStyles', scheme)
|
|
560
|
+
|
|
561
|
+
out = []
|
|
562
|
+
for e in fams.values():
|
|
563
|
+
variants = sorted(e['variants'].values(), key=lambda v: -v['n'])
|
|
564
|
+
weights = sorted({v['weight'] for v in variants if v['weight']})
|
|
565
|
+
row = {'family': e['family'], 'n': e['n'], 'rendered_n': e['rendered'],
|
|
566
|
+
'variants': variants, 'weights': weights,
|
|
567
|
+
'bold_runs': e['bold_runs'],
|
|
568
|
+
'positions': dict(e['positions']), 'slots': dict(e['slots']),
|
|
569
|
+
'sources': dict(e['sources']), 'italic': e['italic'],
|
|
570
|
+
'alias_group': alias_group(e['family']),
|
|
571
|
+
'in_theme': any(v['raw'] in theme_faces for v in variants)}
|
|
572
|
+
if e['theme_refs']:
|
|
573
|
+
row['theme_refs'] = dict(e['theme_refs'])
|
|
574
|
+
if e['unresolved_ref']:
|
|
575
|
+
row['unresolved_theme_ref'] = True
|
|
576
|
+
if not e['rendered']:
|
|
577
|
+
row['renders_no_text'] = True
|
|
578
|
+
out.append(row)
|
|
579
|
+
# rendered_n leads the sort so a face declared only on empty paragraphs cannot
|
|
580
|
+
# outrank one that actually sets type. For families without such declarations
|
|
581
|
+
# rendered_n == n, so the existing order is unchanged.
|
|
582
|
+
out.sort(key=lambda e: (-e['rendered_n'], -e['n'], e['family']))
|
|
583
|
+
return out
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
# --------------------------------------------------------- S7 layout inventory
|
|
587
|
+
def _ph_signature(shapes, part):
|
|
588
|
+
sig = []
|
|
589
|
+
for r in shapes:
|
|
590
|
+
if r['part'] != part or not r.get('ph'):
|
|
591
|
+
continue
|
|
592
|
+
b = r.get('box')
|
|
593
|
+
sig.append((r['ph']['type'],
|
|
594
|
+
tuple(round((b or {}).get(k, -1) / 5.0) for k in ('x', 'y', 'w', 'h'))))
|
|
595
|
+
return tuple(sorted(sig))
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def layout_inventory(pkg, graph, shapes, bg_by_part):
|
|
599
|
+
rows = []
|
|
600
|
+
for lp in pkg.layouts:
|
|
601
|
+
root = pkg.xml(lp)
|
|
602
|
+
cSld = root.find('p:cSld', NS)
|
|
603
|
+
phs = Counter()
|
|
604
|
+
for r in shapes:
|
|
605
|
+
if r['part'] == lp and r.get('ph'):
|
|
606
|
+
phs[r['ph']['type']] += 1
|
|
607
|
+
rows.append({
|
|
608
|
+
'part': lp,
|
|
609
|
+
'name': cSld.get('name') if cSld is not None else None,
|
|
610
|
+
'type_attr': root.get('type', 'cust'),
|
|
611
|
+
'master': graph['master_of_layout'].get(lp),
|
|
612
|
+
'used_by_slides': graph['slides_per_layout'].get(lp, 0),
|
|
613
|
+
'placeholders': dict(phs),
|
|
614
|
+
'shape_n': sum(1 for r in shapes if r['part'] == lp),
|
|
615
|
+
'background': bg_by_part.get(lp),
|
|
616
|
+
'ph_signature': _ph_signature(shapes, lp),
|
|
617
|
+
'guides': [],
|
|
618
|
+
})
|
|
619
|
+
return rows
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def detect_twins(rows):
|
|
623
|
+
"""跨母版孪生检测 (S7, runs before master triage): name match first, geometry fallback."""
|
|
624
|
+
pairs, by_name = [], defaultdict(list)
|
|
625
|
+
for r in rows:
|
|
626
|
+
by_name[(r['name'] or '').strip()].append(r)
|
|
627
|
+
for name, group in by_name.items():
|
|
628
|
+
if not name or len(group) < 2:
|
|
629
|
+
continue
|
|
630
|
+
masters = {r['master'] for r in group}
|
|
631
|
+
if len(masters) < 2:
|
|
632
|
+
continue
|
|
633
|
+
base = group[0]
|
|
634
|
+
for other in group[1:]:
|
|
635
|
+
if other['master'] == base['master']:
|
|
636
|
+
continue
|
|
637
|
+
pairs.append({'a': base['part'], 'b': other['part'], 'name': name,
|
|
638
|
+
'match': 'name',
|
|
639
|
+
'geometry_match': base['ph_signature'] == other['ph_signature']})
|
|
640
|
+
if not pairs:
|
|
641
|
+
by_sig = defaultdict(list)
|
|
642
|
+
for r in rows:
|
|
643
|
+
if r['ph_signature']:
|
|
644
|
+
by_sig[r['ph_signature']].append(r)
|
|
645
|
+
for sig, group in by_sig.items():
|
|
646
|
+
masters = {r['master'] for r in group}
|
|
647
|
+
if len(group) < 2 or len(masters) < 2:
|
|
648
|
+
continue
|
|
649
|
+
base = group[0]
|
|
650
|
+
for other in group[1:]:
|
|
651
|
+
if other['master'] != base['master']:
|
|
652
|
+
pairs.append({'a': base['part'], 'b': other['part'],
|
|
653
|
+
'name': base['name'], 'match': 'geometry',
|
|
654
|
+
'geometry_match': True})
|
|
655
|
+
paired = {p['a'] for p in pairs} | {p['b'] for p in pairs}
|
|
656
|
+
return pairs, sorted(r['part'] for r in rows if r['part'] not in paired)
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
# ------------------------------------------------------------------- S8 guides
|
|
660
|
+
def read_guides(pkg, units, parts):
|
|
661
|
+
"""p:extLst/p15:sldGuideLst on presentation.xml and each slideLayout.
|
|
662
|
+
pos is 1/8 pt. Master level is checked too, purely to evidence that it is empty."""
|
|
663
|
+
out = []
|
|
664
|
+
for part in parts:
|
|
665
|
+
if part not in pkg.names:
|
|
666
|
+
continue
|
|
667
|
+
root = pkg.xml(part)
|
|
668
|
+
for g in root.iter('{%s}guide' % NS['p15']):
|
|
669
|
+
rec = {'part': part, 'orient': g.get('orient', 'vert'),
|
|
670
|
+
'pos_eighth_pt': int(g.get('pos', 0)),
|
|
671
|
+
'px': units.eighth_pt(g.get('pos', 0))}
|
|
672
|
+
clr = g.find('p15:clr', NS)
|
|
673
|
+
if clr is not None:
|
|
674
|
+
for ch in clr:
|
|
675
|
+
c = resolve_color(read_color(ch))
|
|
676
|
+
if c:
|
|
677
|
+
rec['color'] = c.get('hex')
|
|
678
|
+
break
|
|
679
|
+
out.append(rec)
|
|
680
|
+
return out
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
# --------------------------------------------------------- S13 theme topology
|
|
684
|
+
def theme_topology(graph, triage, themes_by_part, clrmap_by_master, bg_by_part, twin_pairs):
|
|
685
|
+
masters = [m['part'] for m in triage['masters'] if m['picked']]
|
|
686
|
+
labels, detail = OrderedDict(), []
|
|
687
|
+
for mp in masters:
|
|
688
|
+
cm = clrmap_by_master.get(mp, {})
|
|
689
|
+
theme = themes_by_part.get(graph['theme_of_master'].get(mp), {})
|
|
690
|
+
scheme = theme.get('clrScheme', {})
|
|
691
|
+
bg = bg_by_part.get(mp) or {}
|
|
692
|
+
bg_hex = ((bg.get('color') or {}).get('hex')
|
|
693
|
+
or scheme.get(cm.get('bg1', 'lt1')))
|
|
694
|
+
lum = luminance(bg_hex) if bg_hex else None
|
|
695
|
+
label = ('dark' if lum is not None and lum < 0.5 else 'light') if bg_hex else None
|
|
696
|
+
detail.append({'master': mp, 'clrMap': cm, 'bg_hex': bg_hex,
|
|
697
|
+
'bg_luminance': round(lum, 4) if lum is not None else None,
|
|
698
|
+
'theme_label': label,
|
|
699
|
+
'bg1_slot': cm.get('bg1'), 'tx1_slot': cm.get('tx1')})
|
|
700
|
+
if label:
|
|
701
|
+
labels.setdefault(label, mp)
|
|
702
|
+
|
|
703
|
+
inversion = []
|
|
704
|
+
for i, a in enumerate(detail):
|
|
705
|
+
for b in detail[i + 1:]:
|
|
706
|
+
ca, cb = a['clrMap'], b['clrMap']
|
|
707
|
+
if not ca or not cb:
|
|
708
|
+
continue
|
|
709
|
+
swapped = [k for k in ('bg1', 'tx1', 'bg2', 'tx2')
|
|
710
|
+
if ca.get(k) and cb.get(k) and ca.get(k) != cb.get(k)]
|
|
711
|
+
pairwise = (ca.get('bg1') == cb.get('tx1') and ca.get('tx1') == cb.get('bg1'))
|
|
712
|
+
if swapped and pairwise:
|
|
713
|
+
inversion.append({'a': a['master'], 'b': b['master'], 'swapped_slots': swapped})
|
|
714
|
+
|
|
715
|
+
if inversion:
|
|
716
|
+
mechanism = 'clrmap-inversion'
|
|
717
|
+
elif twin_pairs and len(labels) > 1:
|
|
718
|
+
mechanism = 'twin-layouts'
|
|
719
|
+
else:
|
|
720
|
+
mechanism = 'single'
|
|
721
|
+
theme_list = list(labels.keys()) if len(labels) > 1 else ['single']
|
|
722
|
+
if mechanism == 'single':
|
|
723
|
+
theme_list = ['single']
|
|
724
|
+
return {
|
|
725
|
+
'themes': theme_list,
|
|
726
|
+
'mechanism': mechanism,
|
|
727
|
+
'default': (list(labels.items())[0][0] if len(labels) > 1 else 'single'),
|
|
728
|
+
'clrmap_by_master': {m: clrmap_by_master.get(m, {}) for m in masters},
|
|
729
|
+
'per_master': detail,
|
|
730
|
+
'inversion_pairs': inversion,
|
|
731
|
+
'twin_pair_n': len(twin_pairs),
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
# ------------------------------------- derived censuses (text/spacing/radii/fx)
|
|
736
|
+
def text_scale(shape_recs, txstyles_by_master):
|
|
737
|
+
agg = {}
|
|
738
|
+
|
|
739
|
+
def add(sz, layer, source, d):
|
|
740
|
+
if not sz:
|
|
741
|
+
return
|
|
742
|
+
e = agg.setdefault(sz, {'sz_px': sz, 'n': 0, 'layers': Counter(),
|
|
743
|
+
'sources': Counter(), 'weights': Counter(),
|
|
744
|
+
'bold': 0, 'lnSpc': Counter()})
|
|
745
|
+
e['n'] += 1
|
|
746
|
+
e['layers'][layer] += 1
|
|
747
|
+
e['sources'][source] += 1
|
|
748
|
+
if d.get('weight'):
|
|
749
|
+
e['weights'][d['weight']] += 1
|
|
750
|
+
if d.get('bold'):
|
|
751
|
+
e['bold'] += 1
|
|
752
|
+
|
|
753
|
+
for r in shape_recs:
|
|
754
|
+
layer = r['layer']
|
|
755
|
+
text = r.get('text') or {}
|
|
756
|
+
for lvl in (text.get('lstStyle') or {}).values():
|
|
757
|
+
add(lvl.get('sz_px'), layer, 'lstStyle', lvl)
|
|
758
|
+
if lvl.get('sz_px') and lvl.get('lnSpc', {}).get('mult'):
|
|
759
|
+
agg[lvl['sz_px']]['lnSpc'][lvl['lnSpc']['mult']] += 1
|
|
760
|
+
for p in text.get('paragraphs', []):
|
|
761
|
+
mult = (p.get('lnSpc') or {}).get('mult')
|
|
762
|
+
runs = [r for r in p.get('runs', []) if not r.get('empty_para')]
|
|
763
|
+
sized = 0
|
|
764
|
+
for run in runs:
|
|
765
|
+
add(run.get('sz_px'), layer, 'run', run)
|
|
766
|
+
if run.get('sz_px'):
|
|
767
|
+
sized += 1
|
|
768
|
+
if mult:
|
|
769
|
+
agg[run['sz_px']]['lnSpc'][mult] += 1
|
|
770
|
+
# a:p/a:pPr/a:defRPr is the default for this paragraph's runs, so it
|
|
771
|
+
# only takes effect where no run overrides it. Counting it alongside
|
|
772
|
+
# an explicit run size would double-count one piece of text.
|
|
773
|
+
dr = p.get('defRPr') or {}
|
|
774
|
+
if runs and not sized and dr.get('sz_px'):
|
|
775
|
+
add(dr['sz_px'], layer, 'pPr', dr)
|
|
776
|
+
if mult:
|
|
777
|
+
agg[dr['sz_px']]['lnSpc'][mult] += 1
|
|
778
|
+
for ts in (txstyles_by_master or {}).values():
|
|
779
|
+
for lvls in (ts or {}).values():
|
|
780
|
+
for lvl in lvls.values():
|
|
781
|
+
add(lvl.get('sz_px'), 'master', 'txStyles', lvl)
|
|
782
|
+
|
|
783
|
+
out = [{'sz_px': e['sz_px'], 'n': e['n'], 'layers': dict(e['layers']),
|
|
784
|
+
'sources': dict(e['sources']), 'weights': dict(e['weights']),
|
|
785
|
+
'bold_runs': e['bold'],
|
|
786
|
+
'line_height_mult': dict(e['lnSpc'].most_common())}
|
|
787
|
+
for e in agg.values()]
|
|
788
|
+
out.sort(key=lambda e: -e['sz_px'])
|
|
789
|
+
return out
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def spacing_candidates(shape_recs, units, top=40):
|
|
793
|
+
W, H = units.w, units.h
|
|
794
|
+
pads = Counter()
|
|
795
|
+
gaps = Counter()
|
|
796
|
+
by_part = defaultdict(list)
|
|
797
|
+
for r in shape_recs:
|
|
798
|
+
b = r.get('box')
|
|
799
|
+
if not b or r.get('placement') not in ('inside', 'bleed'):
|
|
800
|
+
continue
|
|
801
|
+
if (r.get('w_pct') or 0) >= FULLSCREEN_MIN_PCT and (r.get('h_pct') or 0) >= FULLSCREEN_MIN_PCT:
|
|
802
|
+
continue
|
|
803
|
+
if r.get('depth'):
|
|
804
|
+
continue
|
|
805
|
+
by_part[r['part']].append(b)
|
|
806
|
+
for name, v, span in (('left', b['x'], W), ('top', b['y'], H),
|
|
807
|
+
('right', W - (b['x'] + b['w']), W),
|
|
808
|
+
('bottom', H - (b['y'] + b['h']), H)):
|
|
809
|
+
if 0 <= v <= span * 0.4:
|
|
810
|
+
pads[(name, int(round(v)))] += 1
|
|
811
|
+
for boxes in by_part.values():
|
|
812
|
+
for axis, pos, size, cross, cross_size in (('v', 'y', 'h', 'x', 'w'),
|
|
813
|
+
('h', 'x', 'w', 'y', 'h')):
|
|
814
|
+
ordered = sorted(boxes, key=lambda b: b[pos])
|
|
815
|
+
for a, b2 in zip(ordered, ordered[1:]):
|
|
816
|
+
if a[cross] + a[cross_size] <= b2[cross] or b2[cross] + b2[cross_size] <= a[cross]:
|
|
817
|
+
continue
|
|
818
|
+
g = b2[pos] - (a[pos] + a[size])
|
|
819
|
+
if 0 < g <= 400:
|
|
820
|
+
gaps[(axis, int(round(g)))] += 1
|
|
821
|
+
return {
|
|
822
|
+
'paddings': [{'edge': k[0], 'px': k[1], 'n': n}
|
|
823
|
+
for k, n in pads.most_common() if n >= 2][:top],
|
|
824
|
+
'gaps': [{'axis': k[0], 'px': k[1], 'n': n}
|
|
825
|
+
for k, n in gaps.most_common() if n >= 2][:top],
|
|
826
|
+
'grids': fit_grids(shape_recs),
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
# ------------------------------------------------------- 重复网格拟合(栅格)
|
|
831
|
+
GRID_MIN_CELLS = 3 # 少于 3 格谈不上"栅格"
|
|
832
|
+
GRID_TOL_PX = 6.0 # 中心归并容差:同列的元素中心允许这点抖动
|
|
833
|
+
GRID_PITCH_STDEV_MAX = 2.0 # 步距标准差超过它就不算规整
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def _cluster_1d(vals, tol=GRID_TOL_PX):
|
|
837
|
+
"""一维贪心聚类,返回按值排序的簇。"""
|
|
838
|
+
out = []
|
|
839
|
+
for v in sorted(vals):
|
|
840
|
+
if out and v - out[-1][-1] <= tol:
|
|
841
|
+
out[-1].append(v)
|
|
842
|
+
else:
|
|
843
|
+
out.append([v])
|
|
844
|
+
return out
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def _axis_fit(centers, edges):
|
|
848
|
+
"""一轴的列/行拟合。centers 决定分档,edges 给出该档的起始边(供 slot 用)。"""
|
|
849
|
+
groups = _cluster_1d(centers)
|
|
850
|
+
if len(groups) < 2:
|
|
851
|
+
return None
|
|
852
|
+
idx, cur = {}, 0
|
|
853
|
+
for g in groups:
|
|
854
|
+
for v in g:
|
|
855
|
+
idx[v] = cur
|
|
856
|
+
cur += 1
|
|
857
|
+
means = [sum(g) / len(g) for g in groups]
|
|
858
|
+
diffs = [b - a for a, b in zip(means, means[1:])]
|
|
859
|
+
pitch = sum(diffs) / len(diffs)
|
|
860
|
+
var = sum((d - pitch) ** 2 for d in diffs) / len(diffs)
|
|
861
|
+
sd = var ** 0.5
|
|
862
|
+
starts = [None] * len(groups)
|
|
863
|
+
for c, e in zip(centers, edges):
|
|
864
|
+
i = idx[c]
|
|
865
|
+
starts[i] = e if starts[i] is None else min(starts[i], e)
|
|
866
|
+
# 只有 2 档时全轴只有 1 个步距,方差恒为 0——这种"规整"是算法产物不是事实,
|
|
867
|
+
# 至少 3 档(2 个步距)才谈得上验证步距一致性。
|
|
868
|
+
return {'n': len(groups), 'centers': [round(m, 1) for m in means],
|
|
869
|
+
'starts': [round(s, 1) for s in starts],
|
|
870
|
+
'pitch': round(pitch, 1), 'pitch_stdev': round(sd, 2),
|
|
871
|
+
'regular': sd <= GRID_PITCH_STDEV_MAX and len(groups) >= 3}
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def fit_grids(shape_recs):
|
|
875
|
+
"""逐页把同类形状拟合成栅格:列数/列起点/列步距 + 行数/行步距。
|
|
876
|
+
|
|
877
|
+
按**中心**分档而不是按左上角——一排 logo 尺寸各不相同却居中对齐于等宽格,
|
|
878
|
+
用左上角会看不出列(volcano 的 logo 墙就是这个形态,人工数错成 5 列)。
|
|
879
|
+
因此这里也不要求同尺寸,只要求同页同 kind 同层级。
|
|
880
|
+
"""
|
|
881
|
+
buckets = defaultdict(list)
|
|
882
|
+
for r in shape_recs:
|
|
883
|
+
b = r.get('box')
|
|
884
|
+
if not b or r.get('placement') not in ('inside', 'bleed'):
|
|
885
|
+
continue
|
|
886
|
+
if not b.get('w') or not b.get('h'):
|
|
887
|
+
continue
|
|
888
|
+
# 满屏底图不参与栅格(与 spacing_candidates 同口径):它和页面上的小图标
|
|
889
|
+
# 同属 pic、同页,会被凑成一个「2 列」的假栅格。
|
|
890
|
+
if ((r.get('w_pct') or 0) >= FULLSCREEN_MIN_PCT
|
|
891
|
+
and (r.get('h_pct') or 0) >= FULLSCREEN_MIN_PCT):
|
|
892
|
+
continue
|
|
893
|
+
buckets[(r['part'], r['kind'], r.get('depth', 0))].append(b)
|
|
894
|
+
out = []
|
|
895
|
+
for (part, kind, depth), boxes in buckets.items():
|
|
896
|
+
if len(boxes) < GRID_MIN_CELLS:
|
|
897
|
+
continue
|
|
898
|
+
cx = [b['x'] + b['w'] / 2.0 for b in boxes]
|
|
899
|
+
cy = [b['y'] + b['h'] / 2.0 for b in boxes]
|
|
900
|
+
cols = _axis_fit(cx, [b['x'] for b in boxes])
|
|
901
|
+
rows = _axis_fit(cy, [b['y'] for b in boxes])
|
|
902
|
+
keep = [a for a in (cols, rows) if a and a['regular']]
|
|
903
|
+
if not keep:
|
|
904
|
+
continue
|
|
905
|
+
# 至少一轴规整,且格子数够,才算拟合成功
|
|
906
|
+
if (cols['n'] if cols else 1) * (rows['n'] if rows else 1) < GRID_MIN_CELLS:
|
|
907
|
+
continue
|
|
908
|
+
e = {'part': part, 'kind': kind, 'depth': depth, 'n': len(boxes)}
|
|
909
|
+
if cols:
|
|
910
|
+
e['cols'] = cols
|
|
911
|
+
if rows:
|
|
912
|
+
e['rows'] = rows
|
|
913
|
+
if cols and rows:
|
|
914
|
+
e['cells'] = cols['n'] * rows['n']
|
|
915
|
+
e['filled'] = len(boxes)
|
|
916
|
+
w = [b['w'] for b in boxes]
|
|
917
|
+
h = [b['h'] for b in boxes]
|
|
918
|
+
e['item_w'] = [round(min(w), 1), round(max(w), 1)]
|
|
919
|
+
e['item_h'] = [round(min(h), 1), round(max(h), 1)]
|
|
920
|
+
out.append(e)
|
|
921
|
+
out.sort(key=lambda e: (-e['n'], e['part']))
|
|
922
|
+
return out
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
NEGATIVE_EVIDENCE_EFFECTS = ('outerShdw', 'innerShdw', 'glow', 'reflection', 'softEdge', 'blur')
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
def radii_effects_census(shape_recs):
|
|
929
|
+
radii = Counter()
|
|
930
|
+
prst = Counter()
|
|
931
|
+
fx = Counter()
|
|
932
|
+
grads = 0
|
|
933
|
+
alphas = Counter()
|
|
934
|
+
for r in shape_recs:
|
|
935
|
+
if r.get('radius_px') is not None:
|
|
936
|
+
radii[r['radius_px']] += 1
|
|
937
|
+
if r.get('geom'):
|
|
938
|
+
prst[r['geom']['prst']] += 1
|
|
939
|
+
for e in (r.get('effects') or []):
|
|
940
|
+
fx[e['type']] += 1
|
|
941
|
+
f = r.get('fill') or {}
|
|
942
|
+
if f.get('type') == 'gradient':
|
|
943
|
+
grads += 1
|
|
944
|
+
col = f.get('color') or {}
|
|
945
|
+
if col.get('alpha') is not None and col['alpha'] < 100:
|
|
946
|
+
alphas[col['alpha']] += 1
|
|
947
|
+
effects = {k: fx.get(k, 0) for k in NEGATIVE_EVIDENCE_EFFECTS}
|
|
948
|
+
for k, v in fx.items():
|
|
949
|
+
effects.setdefault(k, v)
|
|
950
|
+
return (
|
|
951
|
+
[{'px': px, 'n': n} for px, n in sorted(radii.items(), key=lambda kv: -kv[1])],
|
|
952
|
+
{'prst_geom': dict(prst.most_common()), 'gradient_fills': grads,
|
|
953
|
+
'translucent_fills': {str(k): v for k, v in alphas.most_common()}},
|
|
954
|
+
effects,
|
|
955
|
+
)
|