@lark-apaas/coding-steering 0.1.32-beta.0 → 0.1.32-dev.5abff3b

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 (58) hide show
  1. package/package.json +6 -6
  2. package/steering/design-html/skills/charts/SKILL.md +4 -0
  3. package/steering/design-html/skills/pptx-style-extract/SKILL.md +64 -26
  4. package/steering/design-html/skills/pptx-style-extract/font-fallback.yaml +3 -3
  5. package/steering/design-html/skills/pptx-style-extract/scripts/census.py +18 -12
  6. package/steering/design-html/skills/pptx-style-extract/scripts/check_v2.py +153 -8
  7. package/steering/design-html/skills/pptx-style-extract/scripts/draft.py +2735 -288
  8. package/steering/design-html/skills/pptx-style-extract/scripts/extract.py +325 -22
  9. package/steering/design-html/skills/pptx-style-extract/scripts/ooxml.py +19 -2
  10. package/steering/design-html/skills/pptx-style-extract/scripts/package.py +991 -165
  11. package/steering/design-html/skills/pptx-style-extract/scripts/parts.py +6 -3
  12. package/steering/design-html/skills/pptx-style-extract/scripts/query.py +4 -9
  13. package/steering/design-html/skills/pptx-style-extract/scripts/render_pages.py +16 -10
  14. package/steering/design-html/skills/pptx-style-extract/scripts/test_asset_judgment_package.py +443 -0
  15. package/steering/design-html/skills/pptx-style-extract/scripts/test_background_composite.py +57 -0
  16. package/steering/design-html/skills/pptx-style-extract/scripts/test_color_contract.py +60 -0
  17. package/steering/design-html/skills/pptx-style-extract/scripts/test_design_consumer_contract.py +63 -0
  18. package/steering/design-html/skills/pptx-style-extract/scripts/test_flow_layout_contract.py +528 -0
  19. package/steering/design-html/skills/pptx-style-extract/scripts/test_layout_css.py +1513 -0
  20. package/steering/design-html/skills/pptx-style-extract/scripts/test_rounded_contract.py +112 -0
  21. package/steering/design-html/skills/pptx-style-extract/scripts/test_text_role_contract.py +315 -0
  22. package/steering/design-html/skills/pptx-style-extract/v2-format-spec.md +27 -15
  23. package/steering/design-html/skills/preflight/scripts/probe.sh +0 -0
  24. package/steering/nestjs-react-fullstack/skills/app-init-feasibility-guide/SKILL.md +1 -0
  25. package/steering/nestjs-react-fullstack/skills/authn-guide/SKILL.md +6 -0
  26. package/steering/nestjs-react-fullstack/skills/authz-guide/SKILL.md +5 -5
  27. package/steering/nestjs-react-fullstack/skills/authz-guide/references/dynamic-permission-guide.md +1 -1
  28. package/steering/nestjs-react-fullstack/skills/client-builtins-file-storage-service/SKILL.md +37 -113
  29. package/steering/nestjs-react-fullstack/skills/client-builtins-user-service/SKILL.md +13 -2
  30. package/steering/nestjs-react-fullstack/skills/code-fix/SKILL.md +7 -7
  31. package/steering/nestjs-react-fullstack/skills/coding-guide/SKILL.md +149 -24
  32. package/steering/nestjs-react-fullstack/skills/connections-sdk/SKILL.md +202 -0
  33. package/steering/nestjs-react-fullstack/skills/nestjs-cache/SKILL.md +255 -0
  34. package/steering/nestjs-react-fullstack/skills/plugin-guide/SKILL.md +158 -543
  35. package/steering/nestjs-react-fullstack/skills/plugin-guide/references/plugin-coding-guide.md +15 -1
  36. package/steering/nestjs-react-fullstack/skills/plugin-guide/references/table.md +30 -14
  37. package/steering/nestjs-react-fullstack/skills/raw-sql-boundary-audit/SKILL.md +63 -0
  38. package/steering/nestjs-react-fullstack/skills/server-builtins-file-storage-service/SKILL.md +1 -1
  39. package/steering/nestjs-react-fullstack/skills_common/trigger-guide/SKILL.md +284 -12
  40. package/steering/nestjs-react-fullstack/skills_local/plugin-guide/SKILL.md +4 -0
  41. package/steering/vite-react/skills/plugin-guide/SKILL.md +3 -1
  42. package/steering/vite-react/skills/react-three-fiber/SKILL.md +4 -0
  43. package/steering/nestjs-react-fullstack/skills/client-add-aily-web-chat/SKILL.md +0 -139
  44. package/steering/nestjs-react-fullstack/skills/feishu/SKILL.md +0 -269
  45. package/steering/nestjs-react-fullstack/skills/feishu/references/approval.md +0 -214
  46. package/steering/nestjs-react-fullstack/skills/feishu/references/attendance.md +0 -163
  47. package/steering/nestjs-react-fullstack/skills/feishu/references/bitable.md +0 -311
  48. package/steering/nestjs-react-fullstack/skills/feishu/references/calendar.md +0 -190
  49. package/steering/nestjs-react-fullstack/skills/feishu/references/contacts.md +0 -160
  50. package/steering/nestjs-react-fullstack/skills/feishu/references/doc.md +0 -257
  51. package/steering/nestjs-react-fullstack/skills/feishu/references/drive.md +0 -104
  52. package/steering/nestjs-react-fullstack/skills/feishu/references/events.md +0 -199
  53. package/steering/nestjs-react-fullstack/skills/feishu/references/id-convert.md +0 -128
  54. package/steering/nestjs-react-fullstack/skills/feishu/references/messaging.md +0 -207
  55. package/steering/nestjs-react-fullstack/skills/feishu/references/oauth.md +0 -165
  56. package/steering/nestjs-react-fullstack/skills/feishu/references/perm.md +0 -91
  57. package/steering/nestjs-react-fullstack/skills/feishu/references/wiki.md +0 -165
  58. package/steering/nestjs-react-fullstack/skills_common/trigger-guide/references/trigger-lifecycle.md +0 -301
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env python3
2
- """Stage-1 deterministic extractor for PPTX/POTX style packs (票 09 原型).
2
+ """Stage-1 deterministic extractor for PPTX/POTX style packs.
3
3
 
4
4
  python3 extract.py <pptx> <outdir> [--export-all-media]
5
5
 
@@ -11,6 +11,8 @@ as-is *and* re-encoded to a webp under ASSET_BUDGET_BYTES; without Pillow the
11
11
  transcode falls back to darwin `sips`, and failing that the row records
12
12
  transcode_blocked and extract.json records pillow_available: false.
13
13
  """
14
+ import hashlib
15
+ import io
14
16
  import json
15
17
  import os
16
18
  import re
@@ -21,7 +23,8 @@ from datetime import datetime, timezone
21
23
 
22
24
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
23
25
 
24
- from census import (DHASH_PREFILTER_MAX, FULLSCREEN_MIN_PCT, PIXDIFF_MAX, REPEAT_MIN,
26
+ from census import (ASSET_WARN_SINGLE, DHASH_PREFILTER_MAX, FULLSCREEN_MIN_PCT, PIXDIFF_MAX,
27
+ REPEAT_MIN, SMALL_IMG_W_PCT,
25
28
  EPS_PX, color_census, content_clusters, detect_twins, font_census,
26
29
  image_census, image_palette, layout_inventory, media_fingerprints,
27
30
  font_scheme_by_part, radii_effects_census, read_guides,
@@ -32,7 +35,7 @@ from parts import (Package, PartCtx, build_graph, read_clrmap, read_part_shapes,
32
35
 
33
36
  SCHEMA = 'pptx-extract/stage1-v0.1'
34
37
  WEB_FORMATS = {'png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'}
35
- ASSET_BUDGET_BYTES = 500 * 1024 # D5b WARN line
38
+ ASSET_BUDGET_BYTES = ASSET_WARN_SINGLE # V2-6 单张 WARN 线
36
39
  # Quality ladder first, then resolution — dropping pixels is the more visible
37
40
  # loss, so it is only reached once the lowest quality still overshoots.
38
41
  WEBP_QUALITY_LADDER = (85, 75, 65, 55, 45)
@@ -161,6 +164,7 @@ def _webp_from_sips(part, raw, dst_base, budget):
161
164
  def export_media(pkg, images, outdir, pillow_ok, export_all=False):
162
165
  media_dir = os.path.join(outdir, 'media-out')
163
166
  os.makedirs(media_dir, exist_ok=True)
167
+ written = {}
164
168
  by_media = {i['media']: i for i in images}
165
169
  rows = []
166
170
  for part in pkg.media:
@@ -177,6 +181,10 @@ def export_media(pkg, images, outdir, pillow_ok, export_all=False):
177
181
  reasons.append('variant_group')
178
182
  if info['stitch_candidate']:
179
183
  reasons.append('crop_stitch')
184
+ if 0 < (info.get('max_w_pct') or 0) < SMALL_IMG_W_PCT and not info['fullscreen']:
185
+ # 小图(页内图标、角标)。不导出的话 L 层只能看着装饰容器里的空洞
186
+ # 自己编图形,编出来的与模板无关。
187
+ reasons.append('icon_candidate')
180
188
  if ext in ('svg', 'emf', 'wmf'):
181
189
  reasons.append('vector')
182
190
  row = {'media': part, 'ext': ext, 'bytes': size,
@@ -191,7 +199,15 @@ def export_media(pkg, images, outdir, pillow_ok, export_all=False):
191
199
  body = raw.decode('utf-8', 'replace')
192
200
  row['embedded_raster'] = 'data:image/' in body
193
201
  out_name = os.path.basename(part)
194
- with open(os.path.join(media_dir, out_name), 'wb') as f:
202
+ dst_path = os.path.join(media_dir, out_name)
203
+ if dst_path in written:
204
+ # 两个 media part 落到同一个输出名。不覆盖——覆盖等于悄悄换掉一张图。
205
+ row['export_name_conflict'] = written[dst_path]
206
+ out_name = '%s~%d%s' % (os.path.splitext(out_name)[0], len(written),
207
+ os.path.splitext(out_name)[1])
208
+ dst_path = os.path.join(media_dir, out_name)
209
+ written[dst_path] = part
210
+ with open(dst_path, 'wb') as f:
195
211
  f.write(raw)
196
212
  row.update({'exported': True, 'out': 'media-out/' + out_name,
197
213
  'out_bytes': len(raw), 'transcoded': False,
@@ -210,13 +226,11 @@ def export_media(pkg, images, outdir, pillow_ok, export_all=False):
210
226
  # rasterising it here would throw away the vector original.
211
227
  if needs and ext != 'svg':
212
228
  row['needs_transcode'] = needs
213
- # The compressed file is only ever .webp (Pillow) or .jpg (sips). When
214
- # the source already carries that extension the naive stem would point
215
- # at the exported original and silently overwrite it.
216
- stem = out_name.rsplit('.', 1)[0]
217
- if ext in ('webp', 'jpg'):
218
- stem += '-min'
219
- base = os.path.join(media_dir, stem)
229
+ # 压缩产物必须用**完整原名**当前缀:只去掉扩展名的话,image4.png 的压缩版
230
+ # 就叫 image4.webp —— ppt/media/image4.webp 往往是另一张真实存在的图,
231
+ # 两者抢同一个输出名,后写的覆盖先写的,且全程无人报错。实测某模板因此把
232
+ # 一张 109x109 的小图标当成了整页背景。
233
+ base = os.path.join(media_dir, out_name + '-min')
220
234
  done, why = None, 'pillow-unavailable' if not pillow_ok else None
221
235
  if pillow_ok:
222
236
  try:
@@ -239,18 +253,255 @@ def export_media(pkg, images, outdir, pillow_ok, export_all=False):
239
253
  return rows
240
254
 
241
255
 
256
+ # ---------------------------------------------------- rendered backgrounds
257
+ def _is_full_canvas_picture(shape):
258
+ return (shape.get('kind') == 'pic' and shape.get('media')
259
+ and not shape.get('hidden')
260
+ and (shape.get('w_pct') or 0) >= 95
261
+ and (shape.get('h_pct') or 0) >= 95)
262
+
263
+
264
+ def _picture_has_appearance(shape):
265
+ return bool(shape.get('flipH') or shape.get('flipV') or shape.get('rot')
266
+ or shape.get('crop')
267
+ or (shape.get('opacity') is not None and shape.get('opacity') != 1.0))
268
+
269
+
270
+ def _effective_background(parts, bg_by_part):
271
+ background = None
272
+ for part in parts:
273
+ if bg_by_part.get(part):
274
+ background = bg_by_part[part]
275
+ return background
276
+
277
+
278
+ def _draw_picture(canvas, shape, pkg, image_cache=None):
279
+ """Replay one p:pic's crop, flip, rotation and blip opacity with Pillow."""
280
+ from PIL import Image, ImageEnhance, ImageOps
281
+
282
+ media = shape.get('media')
283
+ if media not in pkg.names:
284
+ return False
285
+ image_cache = image_cache if image_cache is not None else {}
286
+ box = shape.get('box_unrotated') or shape.get('box') or {}
287
+ if media not in image_cache:
288
+ try:
289
+ source = Image.open(io.BytesIO(pkg.zip.read(media))).convert('RGBA')
290
+ source.load()
291
+ image_cache[media] = source
292
+ except Exception:
293
+ return False
294
+ picture = image_cache[media].copy()
295
+
296
+ crop = shape.get('crop') or {}
297
+ left = max(0.0, min(float(crop.get('l', 0) or 0), 100.0))
298
+ top = max(0.0, min(float(crop.get('t', 0) or 0), 100.0))
299
+ right = max(0.0, min(float(crop.get('r', 0) or 0), 100.0))
300
+ bottom = max(0.0, min(float(crop.get('b', 0) or 0), 100.0))
301
+ bounds = (
302
+ int(round(picture.width * left / 100.0)),
303
+ int(round(picture.height * top / 100.0)),
304
+ int(round(picture.width * (1.0 - right / 100.0))),
305
+ int(round(picture.height * (1.0 - bottom / 100.0))),
306
+ )
307
+ if bounds[2] > bounds[0] and bounds[3] > bounds[1]:
308
+ picture = picture.crop(bounds)
309
+
310
+ width = max(1, int(round(box.get('w') or 0)))
311
+ height = max(1, int(round(box.get('h') or 0)))
312
+ resampling = getattr(Image, 'Resampling', Image)
313
+ picture = picture.resize((width, height), resampling.LANCZOS)
314
+ if shape.get('flipH'):
315
+ picture = ImageOps.mirror(picture)
316
+ if shape.get('flipV'):
317
+ picture = ImageOps.flip(picture)
318
+ opacity = shape.get('opacity')
319
+ if opacity is not None:
320
+ picture.putalpha(ImageEnhance.Brightness(picture.getchannel('A')).enhance(
321
+ max(0.0, min(float(opacity), 1.0))))
322
+
323
+ rotation = float(shape.get('rot') or 0)
324
+ if rotation:
325
+ picture = picture.rotate(-rotation, resample=resampling.BICUBIC, expand=True)
326
+ center_x = float(box.get('x') or 0) + width / 2.0
327
+ center_y = float(box.get('y') or 0) + height / 2.0
328
+ x = int(round(center_x - picture.width / 2.0))
329
+ y = int(round(center_y - picture.height / 2.0))
330
+ layer = Image.new('RGBA', canvas.size)
331
+ layer.alpha_composite(picture, (x, y))
332
+ canvas.alpha_composite(layer)
333
+ return True
334
+
335
+
336
+ def _draw_background(canvas, background, pkg, image_cache=None):
337
+ """Draw the effective p:bg underneath picture layers."""
338
+ from PIL import Image
339
+ from render_pages import _grad_image, _grad_stops, _rgba, background_css
340
+
341
+ css = background_css(background)
342
+ canvas.paste(_rgba(css, (255, 255, 255, 255)),
343
+ (0, 0, canvas.width, canvas.height))
344
+ gradient = _grad_stops(css or '')
345
+ if gradient:
346
+ canvas.alpha_composite(
347
+ _grad_image(Image, canvas.width, canvas.height, gradient[0], gradient[1]))
348
+ if background and background.get('type') == 'image' and background.get('media'):
349
+ _draw_picture(canvas, {
350
+ 'media': background['media'],
351
+ 'box_unrotated': {'x': 0, 'y': 0, 'w': canvas.width, 'h': canvas.height},
352
+ 'crop': background.get('crop') or {},
353
+ }, pkg, image_cache)
354
+
355
+
356
+ def _save_background_webp(canvas, path):
357
+ """Prefer lossless output; fall back to a quality ladder when oversized."""
358
+ canvas.convert('RGB').save(path, 'WEBP', lossless=True, method=0)
359
+ if os.path.getsize(path) <= ASSET_BUDGET_BYTES:
360
+ return
361
+ rgb = canvas.convert('RGB')
362
+ for quality in (95, 90, 85, 75):
363
+ rgb.save(path, 'WEBP', quality=quality, method=6)
364
+ if os.path.getsize(path) <= ASSET_BUDGET_BYTES:
365
+ return
366
+
367
+
368
+ def compose_backgrounds(pkg, graph, shapes, bg_by_part, units, outdir, pillow_ok):
369
+ """Flatten non-trivial full-canvas picture stacks into one web-safe asset."""
370
+ if not pillow_ok:
371
+ return {}, [], []
372
+ try:
373
+ from PIL import Image
374
+ except Exception:
375
+ return {}, [], []
376
+
377
+ by_part = defaultdict(list)
378
+ for shape in shapes:
379
+ by_part[shape['part']].append(shape)
380
+
381
+ chains = {}
382
+ for layout in pkg.layouts:
383
+ master = graph['master_of_layout'].get(layout)
384
+ show_master = pkg.xml(layout).get('showMasterSp', '1') != '0'
385
+ chains[layout] = [part for part in ((master if show_master else None), layout) if part]
386
+ for slide in pkg.slides:
387
+ layout = graph['layout_of_slide'].get(slide)
388
+ master = graph['master_of_layout'].get(layout)
389
+ show_master = pkg.xml(slide).get('showMasterSp', '1') != '0'
390
+ if layout:
391
+ show_master = show_master and pkg.xml(layout).get('showMasterSp', '1') != '0'
392
+ chains[slide] = [part for part in (
393
+ (master if show_master else None), layout, slide) if part]
394
+
395
+ media_dir = os.path.join(outdir, 'media-out')
396
+ os.makedirs(media_dir, exist_ok=True)
397
+ part_map, rows_by_media, images_by_media, rendered_specs = {}, {}, {}, {}
398
+ image_cache = {}
399
+ for target, chain in chains.items():
400
+ layers = [shape for part in chain for shape in by_part.get(part, [])
401
+ if _is_full_canvas_picture(shape)]
402
+ if not layers or (len(layers) == 1 and not _picture_has_appearance(layers[0])):
403
+ continue
404
+ source_layers = [{
405
+ 'part': shape['part'], 'media': shape['media'],
406
+ 'crop': shape.get('crop'), 'flipH': bool(shape.get('flipH')),
407
+ 'flipV': bool(shape.get('flipV')), 'rot': shape.get('rot') or 0,
408
+ 'opacity': shape.get('opacity', 1.0),
409
+ } for shape in layers]
410
+ spec = json.dumps({
411
+ 'background': _effective_background(chain, bg_by_part),
412
+ 'layers': [{k: v for k, v in layer.items() if k != 'part'}
413
+ for layer in source_layers],
414
+ }, ensure_ascii=False, sort_keys=True)
415
+ media = rendered_specs.get(spec)
416
+ if media is None:
417
+ canvas = Image.new('RGBA', (units.w, units.h))
418
+ _draw_background(
419
+ canvas, _effective_background(chain, bg_by_part), pkg, image_cache)
420
+ drawn = [shape for shape in layers
421
+ if _draw_picture(canvas, shape, pkg, image_cache)]
422
+ if len(drawn) != len(layers):
423
+ continue
424
+ digest = hashlib.sha256(canvas.tobytes()).hexdigest()[:16]
425
+ media = 'generated/background/bg-composite-%s.webp' % digest
426
+ out = 'media-out/' + os.path.basename(media)
427
+ path = os.path.join(outdir, out)
428
+ if not os.path.exists(path):
429
+ _save_background_webp(canvas, path)
430
+ rendered_specs[spec] = media
431
+ else:
432
+ out = rows_by_media[media]['out']
433
+ path = os.path.join(outdir, out)
434
+ part_map[target] = media
435
+ row = rows_by_media.setdefault(media, {
436
+ 'media': media, 'ext': 'webp', 'bytes': os.path.getsize(path),
437
+ 'used_n': 0, 'reasons': ['background_composite'], 'candidate': True,
438
+ 'exported': True, 'out': out, 'out_bytes': os.path.getsize(path),
439
+ 'transcoded': False, 'generated': True, 'composited_from': source_layers,
440
+ 'part_refs': [],
441
+ })
442
+ row['used_n'] += 1
443
+ row['part_refs'].append(target)
444
+ image = images_by_media.setdefault(media, {
445
+ 'media': media, 'n': 0, 'boxes': [], 'exact_boxes': [],
446
+ 'fullscreen': True, 'fullscreen_n': 0, 'fullscreen_top_cluster_n': 0,
447
+ 'repeat_fixed': [], 'max_w_pct': 100.0, 'bleed': False,
448
+ 'crop_variants': [], 'stitch_candidate': False,
449
+ 'svg_companion': None, 'variant_group': [],
450
+ })
451
+ image['n'] += 1
452
+ image['fullscreen_n'] += 1
453
+ image['fullscreen_top_cluster_n'] += 1
454
+ image['boxes'].append({
455
+ 'box': {'x': 0, 'y': 0, 'w': units.w, 'h': units.h},
456
+ 'box_emu': {'x': 0, 'y': 0, 'cx': units.cx, 'cy': units.cy},
457
+ 'count': 1, 'exact_count': 1, 'exact_variants': 1,
458
+ 'w_pct': 100.0, 'h_pct': 100.0, 'parts': [target],
459
+ 'layers': ['composite'],
460
+ })
461
+ return part_map, list(rows_by_media.values()), list(images_by_media.values())
462
+
463
+
242
464
  # ---------------------------------------------------------------- form hint
465
+ # PowerPoint 出厂版式名(中英两套)。设计师起的名字是「这一页干什么用」,
466
+ # 出厂名只是「这个占位符组合叫什么」——后者不算模板声明了页型。
467
+ STOCK_LAYOUT_NAMES = {
468
+ 'default', 'blank', 'custom layout', 'title slide', 'title and content',
469
+ 'section header', 'two content', 'comparison', 'title only',
470
+ 'content with caption', 'picture with caption', 'title and vertical text',
471
+ 'vertical title and text', 'name card', 'quote with caption', 'true or false',
472
+ '空白', '自定义版式', '标题幻灯片', '标题和内容', '节标题', '两栏内容', '比较',
473
+ '仅标题', '内容与标题', '图片与标题', '标题和竖排文字', '竖排标题与文本',
474
+ }
475
+ _NUM_PREFIX = re.compile(r'^\s*\d+[\s._-]*')
476
+
477
+
478
+ def is_semantic_layout_name(name):
479
+ """版式名是不是设计师起的「页型名」,而不是出厂名或纯编号。
480
+
481
+ 不能按字符数判断——中文页型名两个字就说清了(「封面」「目录」),任何长度门槛
482
+ 都会把整套 CJK 命名的模板判成没有语义版式,进而走错 form 分支。
483
+ """
484
+ n = _NUM_PREFIX.sub('', (name or '').strip())
485
+ if not n or n.strip('0123456789 ._-') == '':
486
+ return False
487
+ return n.lower() not in STOCK_LAYOUT_NAMES
488
+
489
+
243
490
  def form_hint(pkg, graph, layouts):
244
491
  slides_with_ph = 0
245
492
  for sp in pkg.slides:
246
493
  if pkg.xml(sp).findall('.//p:ph', NS):
247
494
  slides_with_ph += 1
248
495
  names = [(l['name'] or '') for l in layouts]
249
- semantic = sum(1 for n in names if len(n) > 3 and n.upper() not in ('DEFAULT', 'BLANK'))
496
+ semantic = sum(1 for n in names if is_semantic_layout_name(n))
250
497
  ev = {'slides_using_placeholders': '%d/%d' % (slides_with_ph, len(pkg.slides)),
251
498
  'layouts': len(pkg.layouts), 'masters': len(pkg.masters),
252
499
  'semantic_layout_names': semantic}
253
- if slides_with_ph and len(pkg.layouts) >= 5 and semantic >= 5:
500
+ # 「模板自带页型声明」的判据是**比例**不是个数:一套只有 4 个版式但全部起了页型名的
501
+ # 精简模板,和一套 30 个版式里 5 个有名字的模板,前者才是真的按页型组织的。
502
+ semantic_ratio = semantic / float(len(pkg.layouts) or 1)
503
+ ev['semantic_layout_ratio'] = round(semantic_ratio, 3)
504
+ if slides_with_ph and semantic >= 2 and semantic_ratio >= 0.5:
254
505
  form = 3
255
506
  elif len(pkg.layouts) > 1 and not slides_with_ph:
256
507
  form = 2
@@ -404,7 +655,7 @@ REF_NOTES = """# ref/ 审计层说明(S12)
404
655
  4. **S6 XPath 口径**(写死并逐项声明):design = `a:solidFill//` 下的颜色 + 渐变
405
656
  `a:gs` 的直接子颜色 + `p:bgRef` 的直接子颜色;editor = `p15:clr`(参考线);
406
657
  aux = `a:buClr` 与 `p:style/a:*Ref`。effectLst 内的阴影色不计入频次。
407
- 5. **S5 计数双口径**:`exact_boxes` 是坐标完全一致的计数(可与票 03 recon 逐数对齐);
658
+ 5. **S5 计数双口径**:`exact_boxes` 是坐标完全一致的计数(可与逐形状人工点数对齐);
408
659
  `boxes[]` 是 ±0.5%({eps:.1f}px)epsilon 聚类计数,会把微偏移的同位实例并进同一簇。
409
660
  两者都落盘,差异即「容差带来的合并」。
410
661
  6. **满屏判定独立阈值**:w ≥ {fs}% 且 h ≥ {fs}%,上不封顶;出血图(>100%)同样计入满屏。
@@ -432,6 +683,30 @@ REF_NOTES = """# ref/ 审计层说明(S12)
432
683
  """
433
684
 
434
685
 
686
+ def dump_source(pkg, outdir):
687
+ """把 PPTX 解压后的原文原样落到 ref/source/。
688
+
689
+ 普查是有损的:它按既定口径抽数,抽不到的、口径外的东西就没了。遇到判断不了的
690
+ 情况(这个形状为什么这么摆、某个字段是什么意思),能直接翻原始 XML 比对着二手
691
+ 数据猜可靠得多。只进中间产物,交付包里没有。
692
+ """
693
+ dst = os.path.abspath(os.path.join(outdir, 'ref', 'source'))
694
+ n = 0
695
+ for name in pkg.zip.namelist():
696
+ if name.endswith('/'):
697
+ continue
698
+ p = os.path.abspath(os.path.join(dst, *name.split('/')))
699
+ # zip 条目名是文件里写什么就是什么,带 ../ 就能写到 ref/source 外面去
700
+ # (pptx 是用户上传的,当不可信输入处理)。落在目录外的条目一律不落盘。
701
+ if not p.startswith(dst + os.sep):
702
+ continue
703
+ os.makedirs(os.path.dirname(p), exist_ok=True)
704
+ with open(p, 'wb') as f:
705
+ f.write(pkg.zip.read(name))
706
+ n += 1
707
+ return n
708
+
709
+
435
710
  def write_ref(outdir, payload, ref_data, units, parts_ordered, shapes, bg_by_part):
436
711
  ref = os.path.join(outdir, 'ref')
437
712
  os.makedirs(ref, exist_ok=True)
@@ -570,11 +845,16 @@ def extract(pptx, outdir, export_all=False):
570
845
  t = mark('derived_censuses', t)
571
846
 
572
847
  media_rows = export_media(pkg, images, outdir, pillow_ok, export_all) # S9
848
+ background_composites, composite_media, composite_images = compose_backgrounds(
849
+ pkg, graph, shapes, bg_by_part, units, outdir, pillow_ok)
850
+ media_rows += composite_media
851
+ images += composite_images
573
852
  t = mark('S9_media_export', t)
574
853
 
575
854
  # S5b + S14 run after S9 because the palette only covers exported assets.
576
- fps = media_fingerprints(pkg, [i['media'] for i in images], pillow_ok)
577
- clusters, cluster_evidence = content_clusters(images, fps)
855
+ source_images = [i for i in images if i['media'] in pkg.names]
856
+ fps = media_fingerprints(pkg, [i['media'] for i in source_images], pillow_ok)
857
+ clusters, cluster_evidence = content_clusters(source_images, fps)
578
858
  exported = {m['media'] for m in media_rows if m.get('exported')}
579
859
  image_palette(images, fps, exported)
580
860
  t = mark('S5b_S14_content_palette', t)
@@ -646,7 +926,8 @@ def extract(pptx, outdir, export_all=False):
646
926
  'Pillow unavailable: byte-identical media only, no perceptual merging'),
647
927
  'palette_available': pillow_ok,
648
928
  'media': media_rows,
649
- # S4 shape-facts are 49-64% of this file (volcano: 1.4 MB) and stage 2 reads
929
+ 'background_composites': background_composites,
930
+ # S4 shape-facts dominate this file (roughly half its bytes) and stage 2 reads
650
931
  # them only when a derived statistic needs backing evidence, so they live in
651
932
  # a sidecar and extract.json keeps just the pointer plus the derived censuses.
652
933
  'shapes_ref': 'ref/shapes.json',
@@ -656,6 +937,7 @@ def extract(pptx, outdir, export_all=False):
656
937
  'slides': len(pkg.slides), 'layouts': len(pkg.layouts),
657
938
  'masters': len(pkg.masters), 'themes': len(pkg.themes),
658
939
  'media': len(pkg.media),
940
+ 'background_composites': len(composite_media),
659
941
  'shapes_total': len(shapes),
660
942
  'shapes_kept': len(kept_shapes),
661
943
  'shapes_dropped_off_canvas': len(dropped_shapes),
@@ -665,8 +947,10 @@ def extract(pptx, outdir, export_all=False):
665
947
  if r.get('placement') == 'inherited'),
666
948
  'content_clusters': len(clusters),
667
949
  'content_clusters_multi_media': sum(1 for c in clusters if c['member_n'] > 1),
668
- 'media_exported': sum(1 for m in media_rows if m.get('exported')),
669
- 'media_transcoded': sum(1 for m in media_rows if m.get('transcoded')),
950
+ 'media_exported': sum(1 for m in media_rows
951
+ if m.get('exported') and not m.get('generated')),
952
+ 'media_transcoded': sum(1 for m in media_rows
953
+ if m.get('transcoded') and not m.get('generated')),
670
954
  'media_transcode_blocked': sum(1 for m in media_rows
671
955
  if m.get('transcode_blocked')),
672
956
  'media_over_budget': sum(1 for m in media_rows if m.get('over_budget')),
@@ -723,6 +1007,7 @@ def extract(pptx, outdir, export_all=False):
723
1007
  with open(out_json, 'w') as f:
724
1008
  json.dump(payload, f, ensure_ascii=False, indent=1)
725
1009
  write_ref(outdir, payload, ref_data, units, parts_ordered, shapes, bg_by_part)
1010
+ src_n = dump_source(pkg, outdir)
726
1011
  perf['total'] = round(time.time() - t0, 3)
727
1012
  with open(os.path.join(outdir, 'ref', 'perf.json'), 'w') as f:
728
1013
  json.dump(perf, f, ensure_ascii=False, indent=1)
@@ -734,9 +1019,11 @@ def extract(pptx, outdir, export_all=False):
734
1019
  print(' shapes %d kept / %d dropped off-canvas / %d bleed / %d clamped'
735
1020
  % (payload['counts']['shapes_kept'], payload['counts']['shapes_dropped_off_canvas'],
736
1021
  payload['counts']['shapes_bleed'], payload['counts']['shapes_clamped']))
737
- print(' colors %d fonts %d images %d media exported %d/%d guides %d'
1022
+ print(' colors %d fonts %d images %d media exported %d/%d + %d composites guides %d'
738
1023
  % (len(color_freq), len(fonts), len(images),
739
- payload['counts']['media_exported'], len(pkg.media), len(guides)))
1024
+ payload['counts']['media_exported'], len(pkg.media),
1025
+ len(composite_media), len(guides)))
1026
+ print(' ref/source/ 原文 %d 个部件(判断不了时可直接翻)' % src_n)
740
1027
  print(' extract.json %.1f KB total %.2fs'
741
1028
  % (os.path.getsize(out_json) / 1024.0, perf['total']))
742
1029
  return payload
@@ -751,13 +1038,29 @@ def main(argv):
751
1038
  print('unknown option(s): %s' % ' '.join(sorted(unknown)))
752
1039
  print(__doc__)
753
1040
  return 2
1041
+ if not os.path.isfile(args[0]):
1042
+ # 猜附件文件名是高频错误起手式,报错要把「去哪儿看真名」直接说清楚
1043
+ print('找不到 %s' % args[0])
1044
+ print('不要猜附件文件名。先列出真实文件:ls -la .agent/<conversation_id>/attachments/')
1045
+ print('目录里没有 .pptx / .potx 时,说明这次上传没有落成沙箱本地文件——'
1046
+ '如实告诉用户拿不到模板文件,不要退回附件文本摘要或自造配图当风格来源。')
1047
+ return 2
754
1048
  extract(args[0], args[1], export_all='--export-all-media' in flags)
755
1049
  if '--no-draft' not in flags:
756
1050
  import subprocess
757
1051
  d = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'draft.py')
1052
+ sys.stdout.flush()
758
1053
  r = subprocess.run([sys.executable, d, args[1]])
759
1054
  if r.returncode:
760
- print(' ⚠ 草案生成失败,可单独重跑 draft.py 看报错')
1055
+ # 普查产物已经齐了,缺的只是草案。重跑整条抽取会同样失败在这一步,
1056
+ # 所以给一个区别于成功的终止哨兵,并指明只需重跑 draft.py。
1057
+ print('EXTRACT_PARTIAL 普查产物齐全,草案生成失败:'
1058
+ 'python3 -B scripts/draft.py %s 单独重跑看报错' % args[1])
1059
+ sys.stdout.flush()
1060
+ return 1
1061
+ # 最后一行是终止哨兵:stdout 被截断时退出码仍可能是 0,两个哨兵都没有就是没跑完。
1062
+ print('EXTRACT_OK %s' % os.path.join(args[1], 'l-out'))
1063
+ sys.stdout.flush()
761
1064
  return 0
762
1065
 
763
1066
 
@@ -47,7 +47,7 @@ WEIGHT_TOKENS = {
47
47
  ITALIC_TOKENS = {'italic', 'oblique', 'it'}
48
48
 
49
49
  # Deterministic latin<->CJK alias hints. Emitted as a separate `alias_group` field,
50
- # never merged into counts, so the audit trail stays intact (see STAGE1-REPORT deviations).
50
+ # never merged into counts, so the audit trail stays intact.
51
51
  FONT_ALIAS_GROUPS = {
52
52
  'fzlantinghei': ('方正兰亭黑', 'fzlantingheipro', 'fzlthpro', 'fzlthpros'),
53
53
  'bytesans': ('字节跳动', 'bytesans'),
@@ -519,8 +519,25 @@ def read_txbody(tx, ctx, kind='txBody'):
519
519
  ins[k] = ctx.units.px(bp.get(k))
520
520
  if ins:
521
521
  b['insets_px'] = ins
522
- if bp.find('a:normAutofit', NS) is not None:
522
+ na = bp.find('a:normAutofit', NS)
523
+ if na is not None:
523
524
  b['autofit'] = 'norm'
525
+ # normAutofit 用 fontScale / lnSpcReduction(单位 1/1000 %)把大字缩进小框——
526
+ # 章节大号数字就靠它让 160px 的字装进 144px 的框。只记 autofit 存在、丢掉
527
+ # fontScale,消费端就拿到未缩放字号 + 原始框高,字比框高,渐变裁切把溢出的
528
+ # 底部切成透明。缺省即 100%(无缩放)。
529
+ fs = na.get('fontScale')
530
+ if fs is not None:
531
+ try:
532
+ b['font_scale'] = round(int(fs) / 100000.0, 4)
533
+ except (TypeError, ValueError):
534
+ pass
535
+ lsr = na.get('lnSpcReduction')
536
+ if lsr is not None:
537
+ try:
538
+ b['ln_spc_reduction'] = round(int(lsr) / 100000.0, 4)
539
+ except (TypeError, ValueError):
540
+ pass
524
541
  elif bp.find('a:spAutoFit', NS) is not None:
525
542
  b['autofit'] = 'shape'
526
543
  if b: