@lark-apaas/coding-steering 0.1.18-dev.4aa21f4 → 0.1.18-dev.4e64c13

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