@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.
@@ -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
+ )