@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,461 @@
1
+ #!/usr/bin/env python3
2
+ """S1 unpack / S2 reference graph + master triage / S3 theme+clrMap / S4 shape facts."""
3
+ import os
4
+ import re
5
+ import zipfile
6
+ from collections import Counter
7
+ from xml.etree import ElementTree as ET
8
+
9
+ from ooxml import (NS, R_EMBED, R_ID, R_LINK, OFFICE_DEFAULT_ACCENTS, OFFICE_DEFAULT_FONTS,
10
+ SCHEME_SLOTS, Units, classify_box, describe_effects, describe_fill,
11
+ describe_geom, describe_line, group_transform, local, luminance,
12
+ read_color, read_lvl, read_txbody, read_xfrm, resolve_color,
13
+ resolve_part, rotated_bbox)
14
+
15
+ SLIDE_RE = re.compile(r'ppt/slides/slide\d+\.xml$')
16
+ LAYOUT_RE = re.compile(r'ppt/slideLayouts/slideLayout\d+\.xml$')
17
+ MASTER_RE = re.compile(r'ppt/slideMasters/slideMaster\d+\.xml$')
18
+ THEME_RE = re.compile(r'ppt/theme/theme\d+\.xml$')
19
+
20
+
21
+ def _num(part):
22
+ m = re.search(r'(\d+)\.xml$', part)
23
+ return int(m.group(1)) if m else 0
24
+
25
+
26
+ class Package:
27
+ """S1: the opened container + part inventory + content-type discrimination."""
28
+
29
+ def __init__(self, path):
30
+ self.path = path
31
+ self.zip = zipfile.ZipFile(path)
32
+ self.names = set(self.zip.namelist())
33
+ self._cache = {}
34
+ ct = self.zip.read('[Content_Types].xml').decode('utf-8', 'replace')
35
+ m = re.search(r'presentationml\.(presentation|template|slideshow)\.main', ct)
36
+ self.kind = m.group(1) if m else 'unknown'
37
+ self.slides = sorted((n for n in self.names if SLIDE_RE.match(n)), key=_num)
38
+ self.layouts = sorted((n for n in self.names if LAYOUT_RE.match(n)), key=_num)
39
+ self.masters = sorted((n for n in self.names if MASTER_RE.match(n)), key=_num)
40
+ self.themes, self.theme_discovery = self._find_themes(ct)
41
+ # zip directory entries ('ppt/media/') must not be counted as assets —
42
+ # 票 03 recon counted one, which is why its media totals run one high.
43
+ self.media = sorted(n for n in self.names
44
+ if n.startswith('ppt/media/') and not n.endswith('/'))
45
+
46
+ def _find_themes(self, ct):
47
+ """Theme parts come from [Content_Types].xml, not from a path pattern.
48
+
49
+ Compressors relocate them — 飞书压缩版 puts them at
50
+ ppt/slideMasters/theme/themeN.xml — and a `ppt/theme/` regex then finds
51
+ nothing to bind the masters to, so every schemeClr resolves to
52
+ UNRESOLVED:no-scheme. The path regex stays as the fallback for packages
53
+ whose content types are unreadable.
54
+ """
55
+ try:
56
+ root = ET.fromstring(ct.encode('utf-8'))
57
+ found = [o.get('PartName', '').lstrip('/') for o in root.findall('ct:Override', NS)
58
+ if (o.get('ContentType') or '').endswith('theme+xml')]
59
+ found = sorted((n for n in found if n in self.names), key=_num)
60
+ except ET.ParseError:
61
+ found = []
62
+ if found:
63
+ return found, 'content-types'
64
+ return sorted((n for n in self.names if THEME_RE.match(n)), key=_num), 'path-regex'
65
+
66
+ def xml(self, part):
67
+ if part not in self._cache:
68
+ self._cache[part] = ET.fromstring(self.zip.read(part))
69
+ return self._cache[part]
70
+
71
+ def rels(self, part):
72
+ d, f = os.path.split(part)
73
+ rp = '%s/_rels/%s.rels' % (d, f)
74
+ out = {}
75
+ if rp in self.names:
76
+ for r in self.xml(rp).findall('rel:Relationship', NS):
77
+ out[r.get('Id')] = {'type': r.get('Type').rsplit('/', 1)[-1],
78
+ 'target': r.get('Target'),
79
+ 'external': r.get('TargetMode') == 'External'}
80
+ return out
81
+
82
+ def size_of(self, part):
83
+ return self.zip.getinfo(part).file_size if part in self.names else 0
84
+
85
+
86
+ class PartCtx:
87
+ """Per-part resolution context: units + the master's clrMap + its theme's clrScheme."""
88
+
89
+ def __init__(self, pkg, part, layer, units, clrmap, clrscheme, master=None, theme=None):
90
+ self.pkg, self.part, self.layer = pkg, part, layer
91
+ self.units, self.clrmap, self.clrscheme = units, clrmap, clrscheme
92
+ self.master, self.theme = master, theme
93
+ self._rels = pkg.rels(part)
94
+
95
+ def media_of(self, rid):
96
+ if not rid:
97
+ return None
98
+ rel = self._rels.get(rid)
99
+ if not rel or rel['external']:
100
+ return None
101
+ return resolve_part(self.part, rel['target'])
102
+
103
+ def rel_target(self, rid):
104
+ rel = self._rels.get(rid)
105
+ return resolve_part(self.part, rel['target']) if rel and not rel['external'] else None
106
+
107
+
108
+ # ------------------------------------------------------------------ S3 theme
109
+ def read_theme(pkg, part):
110
+ root = pkg.xml(part)
111
+ cs = root.find('.//a:clrScheme', NS)
112
+ scheme, scheme_raw = {}, {}
113
+ if cs is not None:
114
+ for slot in cs:
115
+ tag = local(slot.tag)
116
+ for ch in slot:
117
+ raw = read_color(ch)
118
+ if raw:
119
+ scheme_raw[tag] = raw
120
+ r = resolve_color(raw)
121
+ scheme[tag] = (r or {}).get('hex')
122
+ break
123
+ fonts = {}
124
+ fs = root.find('.//a:fontScheme', NS)
125
+ if fs is not None:
126
+ for kind in ('majorFont', 'minorFont'):
127
+ k = fs.find('a:%s' % kind, NS)
128
+ if k is None:
129
+ continue
130
+ d = {}
131
+ for sc in k:
132
+ tag = local(sc.tag)
133
+ if tag in ('latin', 'ea', 'cs') and sc.get('typeface') is not None:
134
+ d[tag] = sc.get('typeface')
135
+ fonts[kind] = d
136
+ accents = [scheme.get('accent%d' % i, '') or '' for i in range(1, 7)]
137
+ factory_colors = bool(accents) and all(
138
+ a.lstrip('#').upper() in OFFICE_DEFAULT_ACCENTS for a in accents if a)
139
+ latins = {fonts.get(k, {}).get('latin') for k in ('majorFont', 'minorFont')}
140
+ factory_fonts = bool(latins - {None}) and all(
141
+ f in OFFICE_DEFAULT_FONTS for f in latins if f)
142
+ bg_styles = len(root.findall('.//a:bgFillStyleLst/*', NS))
143
+ return {
144
+ 'part': part,
145
+ 'name': root.get('name'),
146
+ 'scheme_name': cs.get('name') if cs is not None else None,
147
+ 'clrScheme': scheme,
148
+ 'clrScheme_raw': {k: v.get('type') + ':' + str(v.get('val')) for k, v in scheme_raw.items()},
149
+ 'fontScheme': fonts,
150
+ 'factory_colors': factory_colors,
151
+ 'factory_fonts': factory_fonts,
152
+ 'bg_fill_styles': bg_styles,
153
+ }
154
+
155
+
156
+ def read_clrmap(pkg, master_part):
157
+ cm = pkg.xml(master_part).find('p:clrMap', NS)
158
+ return dict(cm.attrib) if cm is not None else {}
159
+
160
+
161
+ # ------------------------------------------------------------------- S2 graph
162
+ def build_graph(pkg):
163
+ """slide -> layout -> master -> theme, plus master -> layouts ownership."""
164
+ pres = pkg.xml('ppt/presentation.xml')
165
+ prels = pkg.rels('ppt/presentation.xml')
166
+ master_order = []
167
+ for m in pres.findall('p:sldMasterIdLst/p:sldMasterId', NS):
168
+ t = prels.get(m.get(R_ID))
169
+ if t:
170
+ master_order.append(resolve_part('ppt/presentation.xml', t['target']))
171
+ for m in pkg.masters:
172
+ if m not in master_order:
173
+ master_order.append(m)
174
+
175
+ theme_of_master, layouts_of_master = {}, {}
176
+ for mp in master_order:
177
+ mrels = pkg.rels(mp)
178
+ for rel in mrels.values():
179
+ if rel['type'] == 'theme':
180
+ theme_of_master[mp] = resolve_part(mp, rel['target'])
181
+ lids = []
182
+ for l in pkg.xml(mp).findall('p:sldLayoutIdLst/p:sldLayoutId', NS):
183
+ rel = mrels.get(l.get(R_ID))
184
+ if rel:
185
+ lids.append(resolve_part(mp, rel['target']))
186
+ layouts_of_master[mp] = lids
187
+
188
+ master_of_layout = {}
189
+ for mp, lids in layouts_of_master.items():
190
+ for lp in lids:
191
+ master_of_layout.setdefault(lp, mp)
192
+ for lp in pkg.layouts:
193
+ if lp in master_of_layout:
194
+ continue
195
+ for rel in pkg.rels(lp).values():
196
+ if rel['type'] == 'slideMaster':
197
+ master_of_layout[lp] = resolve_part(lp, rel['target'])
198
+
199
+ layout_of_slide = {}
200
+ for sp in pkg.slides:
201
+ for rel in pkg.rels(sp).values():
202
+ if rel['type'] == 'slideLayout':
203
+ layout_of_slide[sp] = resolve_part(sp, rel['target'])
204
+
205
+ slides_per_layout = Counter(layout_of_slide.values())
206
+ slides_per_master = Counter(
207
+ master_of_layout.get(layout_of_slide[sp]) for sp in pkg.slides if sp in layout_of_slide)
208
+ used_themes = {theme_of_master[m] for m in master_order if m in theme_of_master}
209
+ return {
210
+ 'master_order': master_order,
211
+ 'theme_of_master': theme_of_master,
212
+ 'layouts_of_master': layouts_of_master,
213
+ 'master_of_layout': master_of_layout,
214
+ 'layout_of_slide': layout_of_slide,
215
+ 'slides_per_layout': dict(slides_per_layout),
216
+ 'slides_per_master': dict(slides_per_master),
217
+ 'used_themes': sorted(used_themes),
218
+ 'orphan_themes': sorted(set(pkg.themes) - used_themes),
219
+ }
220
+
221
+
222
+ def triage_masters(pkg, graph, twin_pairs):
223
+ """S2 母版三分规则: ① 主母版仅用于冲突裁决 ② 模板态保全链 ③ 引用 0 且不孪生 -> dropped."""
224
+ order = graph['master_order']
225
+ used = graph['slides_per_master']
226
+ layout_used = graph['slides_per_layout']
227
+ n_layouts = len(pkg.layouts)
228
+ unused_layouts = sum(1 for lp in pkg.layouts if layout_used.get(lp, 0) == 0)
229
+ unused_ratio = (unused_layouts / n_layouts) if n_layouts else 0.0
230
+ template_mode = pkg.kind == 'template' or unused_ratio >= 0.8
231
+
232
+ twinned = set()
233
+ for a, b in twin_pairs:
234
+ twinned.add(graph['master_of_layout'].get(a))
235
+ twinned.add(graph['master_of_layout'].get(b))
236
+
237
+ primary = max(order, key=lambda m: (used.get(m, 0), -order.index(m))) if order else None
238
+ entries = []
239
+ for mp in order:
240
+ refs = used.get(mp, 0)
241
+ reasons = []
242
+ if refs:
243
+ reasons.append('referenced-by-%d-slides' % refs)
244
+ if template_mode:
245
+ reasons.append('template-mode')
246
+ if mp in twinned:
247
+ reasons.append('twinned-with-main-chain')
248
+ picked = bool(reasons)
249
+ entries.append({
250
+ 'part': mp, 'theme': graph['theme_of_master'].get(mp),
251
+ 'layouts': len(graph['layouts_of_master'].get(mp, [])),
252
+ 'slide_refs': refs, 'picked': picked,
253
+ 'reasons': reasons or ['zero-refs-and-not-twinned'],
254
+ 'is_primary': mp == primary,
255
+ })
256
+ return {
257
+ 'primary': primary,
258
+ 'primary_role': 'conflict-arbitration-only',
259
+ 'template_mode': template_mode,
260
+ 'template_mode_evidence': {'content_type': pkg.kind,
261
+ 'layouts_unused': unused_layouts,
262
+ 'layouts_total': n_layouts,
263
+ 'unused_ratio': round(unused_ratio, 3)},
264
+ 'masters': entries,
265
+ 'dropped': [e['part'] for e in entries if not e['picked']],
266
+ }
267
+
268
+
269
+ # ------------------------------------------------------------- S4 shape facts
270
+ def _bg_descriptor(root, ctx):
271
+ bg = root.find('p:cSld/p:bg', NS)
272
+ if bg is None:
273
+ return None
274
+ pr = bg.find('p:bgPr', NS)
275
+ if pr is not None:
276
+ d = describe_fill(pr, ctx) or {}
277
+ return {'source': 'bgPr', **d}
278
+ ref = bg.find('p:bgRef', NS)
279
+ if ref is not None:
280
+ col = None
281
+ for ch in ref:
282
+ col = resolve_color(read_color(ch), ctx.clrmap, ctx.clrscheme)
283
+ if col:
284
+ break
285
+ # bgFillStyleLst[idx-1000] is not expanded; the phClr carries the actual hue.
286
+ return {'source': 'bgRef', 'idx': ref.get('idx'), 'color': col,
287
+ 'note': 'theme bgFillStyleLst pattern not expanded'}
288
+ return None
289
+
290
+
291
+ def _ph(nv):
292
+ ph = nv.find('p:nvPr/p:ph', NS) if nv is not None else None
293
+ if ph is None:
294
+ return None
295
+ return {'type': ph.get('type', 'body'), 'idx': ph.get('idx')}
296
+
297
+
298
+ NV_TAGS = {'sp': 'p:nvSpPr', 'pic': 'p:nvPicPr', 'grpSp': 'p:nvGrpSpPr',
299
+ 'cxnSp': 'p:nvCxnSpPr', 'graphicFrame': 'p:nvGraphicFramePr'}
300
+
301
+
302
+ def walk_tree(el, ctx, out, path=(), xf=(1.0, 1.0, 0.0, 0.0), depth=0):
303
+ sx, sy, dx, dy = xf
304
+ U, W, H = ctx.units, ctx.units.w, ctx.units.h
305
+ for sp in el:
306
+ tag = local(sp.tag)
307
+ if tag not in NV_TAGS:
308
+ continue
309
+ nv = sp.find(NV_TAGS[tag], NS)
310
+ cNv = nv.find('p:cNvPr', NS) if nv is not None else None
311
+ name = cNv.get('name') if cNv is not None else None
312
+ rec = {'part': ctx.part, 'layer': ctx.layer, 'kind': tag,
313
+ 'id': cNv.get('id') if cNv is not None else None, 'name': name,
314
+ 'depth': depth}
315
+ if path:
316
+ rec['group_path'] = list(path)
317
+ ph = _ph(nv)
318
+ if ph:
319
+ rec['ph'] = ph
320
+ if cNv is not None and cNv.get('hidden') == '1':
321
+ rec['hidden'] = True
322
+
323
+ raw = read_xfrm(sp, tag)
324
+ if raw:
325
+ ax = raw['x'] * sx + dx
326
+ ay = raw['y'] * sy + dy
327
+ aw, ah = raw['cx'] * sx, raw['cy'] * sy
328
+ rec['box_emu'] = {'x': round(ax), 'y': round(ay), 'cx': round(aw), 'cy': round(ah)}
329
+ bx, by, bw, bh = U.px(ax), U.px(ay), U.px(aw), U.px(ah)
330
+ rec['box_unrotated'] = {'x': bx, 'y': by, 'w': bw, 'h': bh}
331
+ if raw['rot']:
332
+ rec['rot'] = round(raw['rot'], 2)
333
+ rx, ry, rw, rh = rotated_bbox(bx, by, bw, bh, raw['rot'])
334
+ box = {'x': round(rx, 1), 'y': round(ry, 1), 'w': round(rw, 1), 'h': round(rh, 1)}
335
+ else:
336
+ box = dict(rec['box_unrotated'])
337
+ if raw['flipH']:
338
+ rec['flipH'] = True
339
+ if raw['flipV']:
340
+ rec['flipV'] = True
341
+ verdict, over, clamp = classify_box(box['x'], box['y'], box['w'], box['h'], W, H)
342
+ rec['placement'] = verdict
343
+ if over:
344
+ rec['overflow_pct'] = over
345
+ if verdict == 'bleed':
346
+ rec['bleed'] = True
347
+ elif verdict == 'clamped':
348
+ rec['box_before_clamp'] = dict(box)
349
+ box = {'x': clamp[0], 'y': clamp[1], 'w': clamp[2], 'h': clamp[3]}
350
+ rec['clamped'] = True
351
+ rec['box'] = box
352
+ rec['w_pct'] = round(box['w'] / W * 100, 2)
353
+ rec['h_pct'] = round(box['h'] / H * 100, 2)
354
+ if box['w'] == 0 or box['h'] == 0:
355
+ rec['degenerate_axis'] = 'w' if box['w'] == 0 else 'h'
356
+ else:
357
+ rec['placement'] = 'inherited'
358
+
359
+ spPr = sp.find('p:spPr', NS)
360
+ bw = rec.get('box', {}).get('w')
361
+ bh = rec.get('box', {}).get('h')
362
+ geom, radius = describe_geom(spPr, ctx, bw, bh)
363
+ if geom:
364
+ rec['geom'] = geom
365
+ if radius is not None:
366
+ rec['radius_px'] = radius
367
+ f = describe_fill(spPr, ctx)
368
+ if f:
369
+ rec['fill'] = f
370
+ ln = describe_line(spPr, ctx)
371
+ if ln:
372
+ rec['line'] = ln
373
+ ef = describe_effects(spPr, ctx)
374
+ if ef:
375
+ rec['effects'] = ef
376
+ style = sp.find('p:style', NS)
377
+ if style is not None:
378
+ st = {}
379
+ for refname in ('fillRef', 'lnRef', 'effectRef', 'fontRef'):
380
+ e = style.find('a:%s' % refname, NS)
381
+ if e is None:
382
+ continue
383
+ col = None
384
+ for ch in e:
385
+ col = resolve_color(read_color(ch), ctx.clrmap, ctx.clrscheme)
386
+ if col:
387
+ break
388
+ st[refname] = {'idx': e.get('idx'), 'color': col}
389
+ if st:
390
+ rec['styleRef'] = st
391
+
392
+ if tag == 'pic':
393
+ blip = sp.find('p:blipFill/a:blip', NS)
394
+ if blip is not None:
395
+ rec['media'] = ctx.media_of(blip.get(R_EMBED)) or ctx.media_of(blip.get(R_LINK))
396
+ svg = blip.find('a:extLst//asvg:svgBlip', NS)
397
+ if svg is not None:
398
+ rec['media_svg'] = ctx.media_of(svg.get(R_EMBED))
399
+ sr = sp.find('p:blipFill/a:srcRect', NS)
400
+ if sr is not None and sr.attrib:
401
+ rec['crop'] = {k: round(int(v) / 1000.0, 2) for k, v in sr.attrib.items()}
402
+ stretch = sp.find('p:blipFill/a:stretch', NS)
403
+ if stretch is None and sp.find('p:blipFill/a:tile', NS) is not None:
404
+ rec['tile'] = True
405
+ elif tag == 'graphicFrame':
406
+ gd = sp.find('a:graphic/a:graphicData', NS)
407
+ uri = gd.get('uri') if gd is not None else None
408
+ rec['graphic_uri'] = uri
409
+ if gd is not None:
410
+ if gd.find('.//a:tbl', NS) is not None:
411
+ rec['kind'] = 'table'
412
+ tbl = gd.find('.//a:tbl', NS)
413
+ rec['table'] = {'rows': len(tbl.findall('a:tr', NS)),
414
+ 'cols': len(tbl.findall('a:tblGrid/a:gridCol', NS))}
415
+ elif uri and 'chart' in uri:
416
+ rec['kind'] = 'chart'
417
+ elif uri and 'diagram' in uri:
418
+ rec['kind'] = 'diagram'
419
+
420
+ tx = sp.find('p:txBody', NS)
421
+ if tx is None:
422
+ tx = sp.find('a:txBody', NS)
423
+ if tx is not None:
424
+ body = read_txbody(tx, ctx)
425
+ if body:
426
+ rec['text'] = body
427
+ out.append(rec)
428
+
429
+ if tag == 'grpSp':
430
+ gsx, gsy, gdx, gdy = group_transform(sp)
431
+ walk_tree(sp, ctx, out, path + (name,),
432
+ (sx * gsx, sy * gsy, dx + gdx * sx, dy + gdy * sy), depth + 1)
433
+
434
+
435
+ def read_part_shapes(pkg, part, layer, units, clrmap, clrscheme, master=None, theme=None):
436
+ ctx = PartCtx(pkg, part, layer, units, clrmap, clrscheme, master, theme)
437
+ root = pkg.xml(part)
438
+ shapes = []
439
+ tree = root.find('p:cSld/p:spTree', NS)
440
+ if tree is not None:
441
+ walk_tree(tree, ctx, shapes)
442
+ return ctx, shapes, _bg_descriptor(root, ctx)
443
+
444
+
445
+ def read_txstyles(pkg, master_part, ctx):
446
+ ts = pkg.xml(master_part).find('p:txStyles', NS)
447
+ if ts is None:
448
+ return None
449
+ out = {}
450
+ for which in ('titleStyle', 'bodyStyle', 'otherStyle'):
451
+ el = ts.find('p:%s' % which, NS)
452
+ if el is None:
453
+ continue
454
+ lvls = {}
455
+ for lvl in el:
456
+ d = read_lvl(lvl, ctx)
457
+ if d:
458
+ lvls[local(lvl.tag)] = d
459
+ if lvls:
460
+ out[which] = lvls
461
+ return out or None