@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,562 @@
1
+ #!/usr/bin/env python3
2
+ """L 层查询入口:按需从阶段一产物里捞数,避免整读 extract.json / shapes.json。
3
+
4
+ python3 query.py <stage1-outdir> <子命令> [选项]
5
+
6
+ shapes --part slide7.xml [--kind text|pic|shape|table|group] [--ph] [--limit N]
7
+ colors [--top N] [--class design|editor|aux]
8
+ fonts [--all] 默认只列 rendered_n>0 的族
9
+ text-scale [--top N]
10
+ images [--fullscreen] [--repeat] [--top N]
11
+ clusters [--multi] --multi 只看跨文件同素材簇
12
+ media [--candidate]
13
+ summary 阶段一体检:form/themes/UNRESOLVED/降级信号
14
+ get <点路径> 取 extract.json 任意字段(如 get canvas)
15
+ slides 每页背景 + 版式
16
+ layouts 版式清单
17
+
18
+ 输出是给 agent 读的紧凑表格:定宽列 + 表头,数值直接可抄进产物。
19
+ `shapes` 读 ref/shapes.json(体量大,务必带 --part 过滤),其余读 extract.json。
20
+ """
21
+ import argparse
22
+ import json
23
+ import math
24
+ import os
25
+ import sys
26
+ from collections import Counter
27
+
28
+
29
+ def load(outdir, name):
30
+ p = os.path.join(outdir, name)
31
+ if not os.path.exists(p):
32
+ raise SystemExit('query.py: 找不到 %s' % p)
33
+ with open(p, encoding='utf-8') as f:
34
+ return json.load(f)
35
+
36
+
37
+ def short(part):
38
+ return part.rsplit('/', 1)[-1] if part else '-'
39
+
40
+
41
+ def trunc(s, n):
42
+ s = '' if s is None else str(s).replace('\n', ' ')
43
+ return s if len(s) <= n else s[:n - 1] + '…'
44
+
45
+
46
+ def table(rows, headers, aligns=None):
47
+ if not rows:
48
+ print('(无匹配)')
49
+ return
50
+ cols = len(headers)
51
+ w = [len(str(h)) for h in headers]
52
+ for r in rows:
53
+ for i in range(cols):
54
+ w[i] = max(w[i], len(str(r[i])))
55
+ aligns = aligns or ['<'] * cols
56
+ fmt = ' '.join('{:%s%d}' % (aligns[i], w[i]) for i in range(cols))
57
+ print(fmt.format(*headers))
58
+ print(' '.join('-' * w[i] for i in range(cols)))
59
+ for r in rows:
60
+ print(fmt.format(*[('' if x is None else x) for x in r]))
61
+
62
+
63
+ # ------------------------------------------------------------------- 子命令
64
+ KIND_GROUPS = {'text': None, 'pic': ('pic',), 'shape': ('sp', 'cxnSp'),
65
+ 'table': ('table',), 'group': ('grpSp',)}
66
+
67
+
68
+ def cmd_shapes(a, outdir):
69
+ data = load(outdir, os.path.join('ref', 'shapes.json'))
70
+ shapes = data['shapes'] if isinstance(data, dict) else data
71
+ if a.part:
72
+ want = a.part
73
+ shapes = [s for s in shapes if short(s['part']) == want or s['part'] == want]
74
+ if not shapes:
75
+ parts = sorted({short(s['part']) for s in (data['shapes'] if isinstance(data, dict) else data)})
76
+ raise SystemExit('query.py: 没有 part=%s。可选: %s' % (want, ', '.join(parts[:12]) + ' …'))
77
+ if a.kind:
78
+ if a.kind == 'text':
79
+ shapes = [s for s in shapes if (s.get('text') or {}).get('paragraphs')]
80
+ else:
81
+ keep = KIND_GROUPS[a.kind]
82
+ shapes = [s for s in shapes if s.get('kind') in keep]
83
+ if a.ph:
84
+ shapes = [s for s in shapes if s.get('ph')]
85
+ rows = []
86
+ for s in shapes[:a.limit]:
87
+ b = s.get('box') or {}
88
+ txt = ''
89
+ for p in (s.get('text') or {}).get('paragraphs', []):
90
+ txt += ''.join(r.get('text') or '' for r in p.get('runs', []))
91
+ if len(txt) > 40:
92
+ break
93
+ # 有效字号按 OOXML 优先级找第一个有声明的层:run > 段落 defRPr > 本形状 lstStyle
94
+ text = s.get('text') or {}
95
+ sz = next((r.get('sz_px') for p in text.get('paragraphs', [])
96
+ for r in p.get('runs', []) if r.get('sz_px')), None)
97
+ if sz is None:
98
+ sz = next((p['defRPr']['sz_px'] for p in text.get('paragraphs', [])
99
+ if (p.get('defRPr') or {}).get('sz_px')), None)
100
+ if sz is None:
101
+ sz = next((lvl.get('sz_px') for lvl in (text.get('lstStyle') or {}).values()
102
+ if lvl.get('sz_px')), None)
103
+ fill = (s.get('fill') or {}).get('type')
104
+ col = ((s.get('fill') or {}).get('color') or {}).get('resolved')
105
+ rows.append([s.get('kind'), s.get('id'), trunc(s.get('name'), 22),
106
+ '%s/%s' % (s['ph']['type'], s['ph'].get('idx')) if s.get('ph') else '-',
107
+ b.get('x'), b.get('y'), b.get('w'), b.get('h'),
108
+ sz or '-', (fill or '-') + (('=' + col) if col else ''),
109
+ trunc(txt, 34)])
110
+ table(rows, ['kind', 'id', 'name', 'ph', 'x', 'y', 'w', 'h', 'sz', 'fill', 'text'],
111
+ ['<', '>', '<', '<', '>', '>', '>', '>', '>', '<', '<'])
112
+ print('\n%d 个形状%s' % (len(shapes), '(截断到 %d)' % a.limit if len(shapes) > a.limit else ''))
113
+
114
+
115
+ def cmd_colors(a, outdir):
116
+ d = load(outdir, 'extract.json')
117
+ freq = d['color_freq']
118
+ if a.klass:
119
+ freq = [c for c in freq if c['class'] == a.klass]
120
+ rows = [[c.get('resolved'), c['n'], c['class'],
121
+ trunc(','.join(c.get('raw') or []), 40),
122
+ trunc(','.join('%s:%s' % kv for kv in (c.get('layers') or {}).items()), 26)]
123
+ for c in freq[:a.top]]
124
+ table(rows, ['resolved', 'n', 'class', 'raw tokens', 'layers'],
125
+ ['<', '>', '<', '<', '<'])
126
+ print('\n共 %d 支色(design %d / editor %d / aux %d)'
127
+ % (len(d['color_freq']),
128
+ sum(1 for c in d['color_freq'] if c['class'] == 'design'),
129
+ sum(1 for c in d['color_freq'] if c['class'] == 'editor'),
130
+ sum(1 for c in d['color_freq'] if c['class'] == 'aux')))
131
+
132
+
133
+ def cmd_fonts(a, outdir):
134
+ d = load(outdir, 'extract.json')
135
+ fams = d['font_families']
136
+ if not a.all:
137
+ fams = [f for f in fams if f.get('rendered_n', f['n'])]
138
+ rows = [[f['family'], f['n'], f.get('rendered_n', f['n']),
139
+ ','.join(str(w) for w in f.get('weights') or []) or '-',
140
+ f.get('bold_runs', 0),
141
+ trunc(','.join('%s:%s' % kv for kv in (f.get('sources') or {}).items()), 34),
142
+ 'Y' if f.get('in_theme') else '',
143
+ 'renders_no_text' if f.get('renders_no_text') else '']
144
+ for f in fams]
145
+ table(rows, ['family', 'n', 'rendered', 'weights', 'bold', 'sources', 'theme', 'flag'],
146
+ ['<', '>', '>', '<', '>', '<', '<', '<'])
147
+ print('\n提示:判「在用」看 rendered —— n 含空段落声明(不渲染文字)。'
148
+ ' 全部族用 --all。')
149
+
150
+
151
+ def cmd_text_scale(a, outdir):
152
+ d = load(outdir, 'extract.json')
153
+ rows = [[e['sz_px'], e['n'],
154
+ trunc(','.join('%s:%s' % kv for kv in (e.get('sources') or {}).items()), 34),
155
+ trunc(','.join('%s:%s' % kv for kv in (e.get('layers') or {}).items()), 24),
156
+ ','.join(str(w) for w in (e.get('weights') or {})) or '-',
157
+ trunc(','.join(str(k) for k in (e.get('line_height_mult') or {})), 18)]
158
+ for e in d['text_scale'][:a.top]]
159
+ table(rows, ['sz_px', 'n', 'sources', 'layers', 'weights', 'lnSpc'],
160
+ ['>', '>', '<', '<', '<', '<'])
161
+ print('\n共 %d 档字号。sources=pPr 是段落级 defRPr(Mac Office 常用)。'
162
+ % len(d['text_scale']))
163
+
164
+
165
+ def cmd_images(a, outdir):
166
+ d = load(outdir, 'extract.json')
167
+ imgs = d['images']
168
+ if a.fullscreen:
169
+ imgs = [i for i in imgs if i.get('fullscreen')]
170
+ if a.repeat:
171
+ imgs = [i for i in imgs if i.get('repeat_fixed')]
172
+ rows = []
173
+ for i in imgs[:a.top]:
174
+ b = (i.get('boxes') or [{}])[0].get('box') or {}
175
+ dom = ' '.join('%s' % c['hex'] for c in (i.get('dominant_colors') or [])[:3])
176
+ rows.append([short(i['media']), i['n'], len(i.get('boxes') or []),
177
+ b.get('x'), b.get('y'), b.get('w'), b.get('h'),
178
+ 'Y' if i.get('fullscreen') else '',
179
+ len(i.get('repeat_fixed') or []),
180
+ ','.join(i.get('variant_group') or []) or '-',
181
+ i.get('content_id') or '-',
182
+ i.get('luminance') if i.get('luminance') is not None else '-', dom])
183
+ table(rows, ['media', 'n', 'clus', 'x', 'y', 'w', 'h', 'full', 'rep',
184
+ 'vgroup', 'cid', 'lum', 'dominant'],
185
+ ['<', '>', '>', '>', '>', '>', '>', '<', '>', '<', '<', '>', '<'])
186
+ print('\n共 %d 张被引用素材(满屏 %d / 有固定重复位 %d)'
187
+ % (len(d['images']), sum(1 for i in d['images'] if i.get('fullscreen')),
188
+ sum(1 for i in d['images'] if i.get('repeat_fixed'))))
189
+
190
+
191
+ # ----------------------------------------------------- L11 配方(现成 CSS)
192
+ def _css_color(c):
193
+ """{resolved} 已是 #HEX 或 rgba(...),直接用。"""
194
+ if not isinstance(c, dict):
195
+ return None
196
+ return c.get('resolved') or c.get('hex')
197
+
198
+
199
+ def _css_gradient(g):
200
+ """OOXML gradFill → CSS linear-gradient。ang 自 +x 轴顺时针,CSS 自 12 点,故 +90。"""
201
+ stops = sorted(g.get('stops') or [], key=lambda s: s.get('pos', 0))
202
+ if not stops:
203
+ return None
204
+ parts = ['%s %g%%' % (_css_color(s.get('color')) or 'transparent', s.get('pos', 0))
205
+ for s in stops]
206
+ return 'linear-gradient(%gdeg, %s)' % ((g.get('angle_deg') or 0) + 90, ', '.join(parts))
207
+
208
+
209
+ def _fill_css(f):
210
+ if not isinstance(f, dict):
211
+ return None, None
212
+ t = f.get('type')
213
+ if t == 'solid':
214
+ c = _css_color(f.get('color'))
215
+ return ('background: %s' % c, c) if c else (None, None)
216
+ if t == 'gradient':
217
+ g = _css_gradient(f)
218
+ return ('background-image: %s' % g, g) if g else (None, None)
219
+ if t == 'image':
220
+ return 'background-image: url(%s)' % (f.get('media') or '').rsplit('/', 1)[-1], None
221
+ if t == 'none':
222
+ return 'background: transparent', 'transparent'
223
+ return None, None
224
+
225
+
226
+ def _recipe_css(fill, line, radii, effects):
227
+ """一组形状 → 可直接粘贴的 CSS 声明块。"""
228
+ out = []
229
+ fill_decl, fill_val = _fill_css(fill)
230
+ line_grad = _css_gradient(line['gradient']) if (line or {}).get('gradient') else None
231
+ w = int(round((line or {}).get('w_px') or 0)) if line and not (line or {}).get('none') else 0
232
+ if line_grad and w:
233
+ # 渐变描边只能用双背景实现:内层填充走 padding-box,描边渐变走 border-box
234
+ base = fill_val if fill_val and fill_val.startswith('linear-gradient') else \
235
+ 'linear-gradient(%s, %s)' % (fill_val or 'transparent', fill_val or 'transparent')
236
+ out.append('border: %dpx solid transparent' % w)
237
+ out.append('background: %s padding-box,\n %s border-box'
238
+ % (base, line_grad))
239
+ else:
240
+ if fill_decl:
241
+ out.append(fill_decl)
242
+ if w:
243
+ lc = _css_color((line or {}).get('color')) or 'currentColor'
244
+ dash = (line or {}).get('dash')
245
+ style = 'dashed' if dash and 'dash' in dash else 'solid'
246
+ out.append('border: %dpx %s %s' % (w, style, lc))
247
+ if radii:
248
+ lo, hi = min(radii), max(radii)
249
+ if abs(hi - lo) <= 0.5:
250
+ out.append('border-radius: %gpx' % round(lo, 1))
251
+ else:
252
+ out.append('border-radius: %gpx\x00/* 源内 %g~%gpx 共 %d 档,归一档位由 L11 定 */'
253
+ % (round(sum(radii) / len(radii), 1), round(lo, 1), round(hi, 1),
254
+ len(set(round(r, 1) for r in radii))))
255
+ for e in effects or []:
256
+ if e.get('type') == 'outerShdw':
257
+ col = _css_color(e.get('color')) or 'rgba(0,0,0,0.25)'
258
+ blur = e.get('blurRad_px') or 0
259
+ dist = e.get('dist_px') or 0
260
+ ang = e.get('dir_deg') or 0
261
+ dx = dist * math.cos(math.radians(ang))
262
+ dy = dist * math.sin(math.radians(ang))
263
+ out.append('box-shadow: %.1fpx %.1fpx %.1fpx %s' % (dx, dy, blur, col))
264
+ return out
265
+
266
+
267
+ def _sig(fill, line, effects):
268
+ """分组键 = 填充 + 描边 + 效果。**不含圆角**——OOXML 圆角是 min(w,h) 的百分比,
269
+ 同一配方在不同尺寸的卡上绝对 px 必然不同(feishu 三张 glassCard 实测 10.8/12.8/19.5),
270
+ 把它计入键会把一个配方拆成三组;归一到哪一档是 L11 的判断,脚本只报区间。"""
271
+ f = 'none'
272
+ if isinstance(fill, dict):
273
+ if fill.get('type') == 'solid':
274
+ f = 'solid:%s' % _css_color(fill.get('color'))
275
+ elif fill.get('type') == 'gradient':
276
+ f = 'grad:%s' % _css_gradient(fill)
277
+ else:
278
+ f = fill.get('type') or 'none'
279
+ ln = 'none'
280
+ if isinstance(line, dict) and not line.get('none'):
281
+ if line.get('gradient'):
282
+ ln = 'grad:%g:%s' % (line.get('w_px') or 0, _css_gradient(line['gradient']))
283
+ elif line.get('color'):
284
+ ln = '%g:%s:%s' % (line.get('w_px') or 0, _css_color(line['color']),
285
+ line.get('dash') or '')
286
+ fx = ','.join(sorted(e.get('type', '') for e in effects or []))
287
+ return (f, ln, fx)
288
+
289
+
290
+ def cmd_recipes(a, outdir):
291
+ data = load(outdir, os.path.join('ref', 'shapes.json'))
292
+ shapes = data['shapes'] if isinstance(data, dict) else data
293
+ groups = {}
294
+ for s in shapes:
295
+ fill, line = s.get('fill'), s.get('line')
296
+ fx = s.get('effects')
297
+ radius = s.get('radius_px')
298
+ if not fill and not line and not fx:
299
+ continue
300
+ if isinstance(fill, dict) and fill.get('type') == 'image' and not a.all:
301
+ continue # 图片填充是素材不是配方
302
+ k = _sig(fill, line, fx)
303
+ if k[0] == 'none' and k[1] == 'none' and not k[2]:
304
+ continue
305
+ g = groups.setdefault(k, {'n': 0, 'parts': Counter(), 'sizes': [], 'radii': [],
306
+ 'fill': fill, 'line': line, 'fx': fx})
307
+ g['n'] += 1
308
+ if radius:
309
+ g['radii'].append(radius)
310
+ g['parts'][short(s['part'])] += 1
311
+ b = s.get('box') or {}
312
+ if b.get('w'):
313
+ g['sizes'].append((b['w'], b['h']))
314
+ rows = sorted(groups.values(), key=lambda g: -g['n'])
315
+ rows = [g for g in rows if g['n'] >= a.min]
316
+ if not rows:
317
+ print('(无满足 --min %d 的配方组)' % a.min)
318
+ return
319
+ for i, g in enumerate(rows[:a.top], 1):
320
+ css = _recipe_css(g['fill'], g['line'], g['radii'], g['fx'])
321
+ pages = ', '.join('%s×%d' % (p, n) if n > 1 else p
322
+ for p, n in g['parts'].most_common(6))
323
+ if len(g['parts']) > 6:
324
+ pages += ' …共 %d 处' % len(g['parts'])
325
+ sz = ''
326
+ if g['sizes']:
327
+ ws = sorted(set(round(w) for w, _ in g['sizes']))
328
+ hs = sorted(set(round(h) for _, h in g['sizes']))
329
+ sz = ' 尺寸 w%s h%s' % (ws[0] if len(ws) == 1 else '%d~%d' % (ws[0], ws[-1]),
330
+ hs[0] if len(hs) == 1 else '%d~%d' % (hs[0], hs[-1]))
331
+ print('[r%d] 出现 %d 次%s' % (i, g['n'], sz))
332
+ print(' 页: %s' % pages)
333
+ for decl in css:
334
+ sub = decl.split('\n')
335
+ for j, ln in enumerate(sub):
336
+ if j == len(sub) - 1:
337
+ head, _, note = ln.partition('\x00')
338
+ print(' %s;%s' % (head, (' ' + note) if note else ''))
339
+ else:
340
+ print(' %s' % ln)
341
+ print()
342
+ print('共 %d 组配方(出现 ≥%d 次)。命名与取舍由 L11 判断,CSS 已可直接粘贴。'
343
+ % (len(rows), a.min))
344
+
345
+
346
+ def cmd_grids(a, outdir):
347
+ d = load(outdir, 'extract.json')
348
+ grids = (d.get('spacing_candidates') or {}).get('grids') or []
349
+ rows = []
350
+ for g in grids:
351
+ c, r = g.get('cols'), g.get('rows')
352
+ fmt = lambda x: ('%d @%gpx' % (x['n'], x['pitch']) if x['regular']
353
+ else '(%d @%gpx 不规整)' % (x['n'], x['pitch'])) if x else '-'
354
+ rows.append([short(g['part']), g['kind'], g['n'],
355
+ fmt(c), c['pitch_stdev'] if c else '-',
356
+ fmt(r), r['pitch_stdev'] if r else '-',
357
+ '%s/%s' % (g.get('filled', '-'), g.get('cells', '-')),
358
+ '%g~%g' % tuple(g['item_w']), '%g~%g' % tuple(g['item_h'])])
359
+ table(rows, ['part', 'kind', 'n', 'cols', 'c-sd', 'rows', 'r-sd',
360
+ 'filled/cells', 'item w', 'item h'],
361
+ ['<', '<', '>', '<', '>', '<', '>', '<', '<', '<'])
362
+ if a.part:
363
+ for g in grids:
364
+ if short(g['part']) != a.part:
365
+ continue
366
+ print('\n%s %s 详情:' % (short(g['part']), g['kind']))
367
+ for ax in ('cols', 'rows'):
368
+ if ax in g:
369
+ x = g[ax]
370
+ print(' %s n=%d pitch=%g stdev=%g regular=%s'
371
+ % (ax, x['n'], x['pitch'], x['pitch_stdev'], x['regular']))
372
+ print(' 中心 %s' % x['centers'])
373
+ print(' 起点 %s' % x['starts'])
374
+ print('\n%d 组栅格。中心分档(非左上角)——同格内元素尺寸可不同仍算同列。'
375
+ '带「不规整」的轴步距方差超阈值,只作参考不要照抄。' % len(grids))
376
+
377
+
378
+ def cmd_clusters(a, outdir):
379
+ d = load(outdir, 'extract.json')
380
+ cl = d.get('media_clusters') or []
381
+ if a.multi:
382
+ cl = [c for c in cl if c['member_n'] > 1]
383
+ rows = []
384
+ for c in cl:
385
+ b = (c.get('boxes') or [{}])[0]
386
+ rows.append([c['content_id'], c['member_n'], c['n'],
387
+ 'Y' if c.get('sha256_identical') else '',
388
+ len(c.get('repeat_fixed') or []),
389
+ len(c.get('repeat_fixed_cross_media') or []),
390
+ trunc(','.join(short(m) for m in c['members']), 52)])
391
+ table(rows, ['cid', 'files', 'n', 'sha=', 'rep', 'xrep', 'members'],
392
+ ['<', '>', '>', '<', '>', '>', '<'])
393
+ print('\n%d 簇(跨文件同素材 %d 簇)。xrep>0 = 同素材散成多文件却占同一位置。'
394
+ % (len(d.get('media_clusters') or []),
395
+ sum(1 for c in (d.get('media_clusters') or []) if c['member_n'] > 1)))
396
+
397
+
398
+ def cmd_media(a, outdir):
399
+ d = load(outdir, 'extract.json')
400
+ rows = []
401
+ for m in d['media']:
402
+ if a.candidate and not m.get('candidate'):
403
+ continue
404
+ rows.append([short(m['media']), m['ext'], m['bytes'],
405
+ 'Y' if m.get('candidate') else '', m.get('used_n', 0),
406
+ short(m.get('out')) if m.get('exported') else '-',
407
+ short(m.get('compressed_out')) or '-',
408
+ m.get('compressed_bytes') or '-',
409
+ trunc(','.join(m.get('reasons') or []), 34)])
410
+ table(rows, ['media', 'ext', 'bytes', 'cand', 'used', 'out', 'compressed',
411
+ 'comp_bytes', 'reasons'],
412
+ ['<', '<', '>', '<', '>', '<', '<', '>', '<'])
413
+ print('\n%d 个 media,导出 %d,转码 %d。压缩图 = compressed 列那份,原图 = out 列。'
414
+ % (len(d['media']), d['counts'].get('media_exported', 0),
415
+ d['counts'].get('media_transcoded', 0)))
416
+
417
+
418
+ def cmd_slides(a, outdir):
419
+ d = load(outdir, 'extract.json')
420
+ rows = []
421
+ for s in d.get('slides') or []:
422
+ bg = s.get('background') or {}
423
+ col = (bg.get('color') or {}).get('resolved')
424
+ rows.append([short(s['part']), short(s.get('layout')),
425
+ bg.get('type') or (bg.get('source') or '-'),
426
+ col or '-',
427
+ len(bg.get('stops') or []) or '-'])
428
+ table(rows, ['slide', 'layout', 'bg type', 'bg color', 'stops'])
429
+ print('\n%d 页实例页' % len(d.get('slides') or []))
430
+
431
+
432
+ def cmd_layouts(a, outdir):
433
+ d = load(outdir, 'extract.json')
434
+ rows = []
435
+ for l in d['layouts']:
436
+ bg = l.get('background') or {}
437
+ rows.append([short(l['part']), trunc(l.get('name'), 26), l.get('type_attr'),
438
+ short(l.get('master')), l.get('used_by_slides'), l.get('shape_n'),
439
+ trunc(','.join('%s:%s' % kv for kv in (l.get('placeholders') or {}).items()), 30),
440
+ (bg.get('color') or {}).get('resolved') or bg.get('type') or '-'])
441
+ table(rows, ['layout', 'name', 'type', 'master', 'used', 'shapes', 'placeholders', 'bg'],
442
+ ['<', '<', '<', '<', '>', '>', '<', '<'])
443
+ print('\n%d 个版式;form_hint=%s(%s)'
444
+ % (len(d['layouts']), d['form_hint']['form'], d['form_hint']['note']))
445
+
446
+
447
+
448
+ def cmd_summary(a, outdir):
449
+ d = load(outdir, 'extract.json')
450
+ fh = d.get('form_hint') or {}
451
+ tt = d.get('theme_topology') or {}
452
+ counts = d.get('counts') or {}
453
+ cv = d.get('canvas') or {}
454
+ print('canvas : %s px 源 %s EMU' % (cv.get('px'), (cv.get('source') or {}).get('cx')))
455
+ print('form : %s (%s)' % (fh.get('form'), trunc(fh.get('note'), 72)))
456
+ print('themes : %s mechanism=%s' % (tt.get('themes'), tt.get('mechanism')))
457
+ print('slides : %s layouts %s / masters %s' % (
458
+ counts.get('slides'), counts.get('layouts'), counts.get('masters')))
459
+ print('colors : %s 条 fonts %s 族 images %s' % (
460
+ len(d.get('color_freq') or []), len(d.get('font_families') or []),
461
+ len(d.get('images') or [])))
462
+ multi = [c for c in d.get('media_clusters') or [] if len(c.get('members') or []) > 1]
463
+ if multi:
464
+ print('clusters : %d 个跨文件同素材簇(query.py clusters --multi 细看)' % len(multi))
465
+ warn = 0
466
+ unresolved = sum(c.get('n', 0) for c in d.get('color_freq') or []
467
+ if 'UNRESOLVED' in str(c.get('resolved', '')))
468
+ if unresolved:
469
+ warn += 1
470
+ print('⚠ UNRESOLVED 色 %d 次——schemeClr 解析有残留,查 ref/ 定位,不能带进语义层' % unresolved)
471
+ if not d.get('pillow_available', True):
472
+ warn += 1
473
+ print('⚠ Pillow 不可用——聚类/取色/转码已降级(%s),产物记 gaps' % d.get('content_cluster_mode'))
474
+ if d.get('scheme_fallback'):
475
+ print('note: scheme_fallback 触发 %d 次(rels 断链回退,详见 extract.json)' % len(d['scheme_fallback']))
476
+ if not warn:
477
+ print('OK: 无阻塞信号,可进语义层')
478
+ return 0
479
+
480
+
481
+
482
+ def cmd_get(a, outdir):
483
+ """按点路径取 extract.json 任意字段,如 get canvas / get theme_topology.mechanism"""
484
+ d = load(outdir, 'extract.json')
485
+ cur = d
486
+ for seg in a.path.split('.'):
487
+ if isinstance(cur, list):
488
+ cur = cur[int(seg)]
489
+ elif isinstance(cur, dict):
490
+ if seg not in cur:
491
+ raise SystemExit('get: 无字段 %s(同级键: %s)' % (seg, ', '.join(sorted(cur)[:20])))
492
+ cur = cur[seg]
493
+ else:
494
+ raise SystemExit('get: %s 已是标量' % seg)
495
+ print(json.dumps(cur, ensure_ascii=False, indent=1)[:20000])
496
+
497
+
498
+ def main(argv=None):
499
+ ap = argparse.ArgumentParser(description=__doc__,
500
+ formatter_class=argparse.RawDescriptionHelpFormatter)
501
+ ap.add_argument('outdir')
502
+ sub = ap.add_subparsers(dest='cmd', required=True)
503
+
504
+ p = sub.add_parser('shapes')
505
+ p.add_argument('--part')
506
+ p.add_argument('--kind', choices=sorted(KIND_GROUPS))
507
+ p.add_argument('--ph', action='store_true')
508
+ p.add_argument('--limit', type=int, default=200)
509
+ p.set_defaults(fn=cmd_shapes)
510
+
511
+ p = sub.add_parser('colors')
512
+ p.add_argument('--top', type=int, default=40)
513
+ p.add_argument('--class', dest='klass', choices=('design', 'editor', 'aux'))
514
+ p.set_defaults(fn=cmd_colors)
515
+
516
+ p = sub.add_parser('fonts')
517
+ p.add_argument('--all', action='store_true')
518
+ p.set_defaults(fn=cmd_fonts)
519
+
520
+ p = sub.add_parser('text-scale')
521
+ p.add_argument('--top', type=int, default=60)
522
+ p.set_defaults(fn=cmd_text_scale)
523
+
524
+ p = sub.add_parser('images')
525
+ p.add_argument('--fullscreen', action='store_true')
526
+ p.add_argument('--repeat', action='store_true')
527
+ p.add_argument('--top', type=int, default=60)
528
+ p.set_defaults(fn=cmd_images)
529
+
530
+ p = sub.add_parser('clusters')
531
+ p.add_argument('--multi', action='store_true')
532
+ p.set_defaults(fn=cmd_clusters)
533
+
534
+ p = sub.add_parser('media')
535
+ p.add_argument('--candidate', action='store_true')
536
+ p.set_defaults(fn=cmd_media)
537
+
538
+ sub.add_parser('summary').set_defaults(fn=cmd_summary)
539
+
540
+ p = sub.add_parser('get')
541
+ p.add_argument('path')
542
+ p.set_defaults(fn=cmd_get)
543
+ p = sub.add_parser('recipes')
544
+ p.add_argument('--min', type=int, default=2)
545
+ p.add_argument('--top', type=int, default=20)
546
+ p.add_argument('--all', action='store_true')
547
+ p.set_defaults(fn=cmd_recipes)
548
+
549
+ p = sub.add_parser('grids')
550
+ p.add_argument('--part')
551
+ p.set_defaults(fn=cmd_grids)
552
+
553
+ sub.add_parser('slides').set_defaults(fn=cmd_slides)
554
+ sub.add_parser('layouts').set_defaults(fn=cmd_layouts)
555
+
556
+ a = ap.parse_args(argv)
557
+ a.fn(a, os.path.abspath(a.outdir))
558
+ return 0
559
+
560
+
561
+ if __name__ == '__main__':
562
+ sys.exit(main())