@lark-apaas/coding-steering 0.1.32-dev.4f80f68 → 0.1.32-dev.6f4e4bc

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 (19) hide show
  1. package/package.json +1 -1
  2. package/steering/design-html/skills/pptx-style-extract/SKILL.md +28 -19
  3. package/steering/design-html/skills/pptx-style-extract/scripts/census.py +8 -2
  4. package/steering/design-html/skills/pptx-style-extract/scripts/draft.py +1153 -179
  5. package/steering/design-html/skills/pptx-style-extract/scripts/extract.py +229 -10
  6. package/steering/design-html/skills/pptx-style-extract/scripts/ooxml.py +18 -1
  7. package/steering/design-html/skills/pptx-style-extract/scripts/package.py +643 -40
  8. package/steering/design-html/skills/pptx-style-extract/scripts/parts.py +19 -3
  9. package/steering/design-html/skills/pptx-style-extract/scripts/render_pages.py +4 -2
  10. package/steering/design-html/skills/pptx-style-extract/scripts/test_asset_judgment_package.py +556 -0
  11. package/steering/design-html/skills/pptx-style-extract/scripts/test_background_composite.py +308 -1
  12. package/steering/design-html/skills/pptx-style-extract/scripts/test_design_consumer_contract.py +7 -0
  13. package/steering/design-html/skills/pptx-style-extract/scripts/test_flow_layout_contract.py +157 -7
  14. package/steering/design-html/skills/pptx-style-extract/scripts/test_layout_css.py +1519 -2
  15. package/steering/design-html/skills/pptx-style-extract/scripts/test_logo_scope.py +301 -0
  16. package/steering/design-html/skills/pptx-style-extract/scripts/test_text_role_contract.py +151 -4
  17. package/steering/design-html/skills/pptx-style-extract/scripts/verify_layout_assets.py +206 -0
  18. package/steering/design-html/skills/pptx-style-extract/scripts/verify_logo_scope.py +12 -0
  19. package/steering/design-html/skills/pptx-style-extract/v2-format-spec.md +1 -1
@@ -181,6 +181,8 @@ def export_media(pkg, images, outdir, pillow_ok, export_all=False):
181
181
  reasons.append('variant_group')
182
182
  if info['stitch_candidate']:
183
183
  reasons.append('crop_stitch')
184
+ if info.get('visible_instance_blocked'):
185
+ reasons.append('visible_instance_fallback')
184
186
  if 0 < (info.get('max_w_pct') or 0) < SMALL_IMG_W_PCT and not info['fullscreen']:
185
187
  # 小图(页内图标、角标)。不导出的话 L 层只能看着装饰容器里的空洞
186
188
  # 自己编图形,编出来的与模板无关。
@@ -261,6 +263,30 @@ def _is_full_canvas_picture(shape):
261
263
  and (shape.get('h_pct') or 0) >= 95)
262
264
 
263
265
 
266
+ def _shape_has_visible_text(shape):
267
+ text = shape.get('text') or {}
268
+ for paragraph in text.get('paragraphs') or []:
269
+ for run in paragraph.get('runs') or []:
270
+ if str(run.get('text') or '').strip():
271
+ return True
272
+ return False
273
+
274
+
275
+ def _is_full_canvas_fill_overlay(shape):
276
+ fill = shape.get('fill') or {}
277
+ geometry = shape.get('geom') or {}
278
+ line = shape.get('line') or {}
279
+ return (shape.get('kind') == 'sp' and not shape.get('hidden')
280
+ and fill.get('type') in ('solid', 'gradient')
281
+ and not fill.get('path')
282
+ and geometry.get('prst') == 'rect'
283
+ and not shape.get('rot')
284
+ and not line.get('color') and not line.get('gradient')
285
+ and not _shape_has_visible_text(shape)
286
+ and (shape.get('w_pct') or 0) >= 95
287
+ and (shape.get('h_pct') or 0) >= 95)
288
+
289
+
264
290
  def _picture_has_appearance(shape):
265
291
  return bool(shape.get('flipH') or shape.get('flipV') or shape.get('rot')
266
292
  or shape.get('crop')
@@ -333,6 +359,147 @@ def _draw_picture(canvas, shape, pkg, image_cache=None):
333
359
  return True
334
360
 
335
361
 
362
+ def _visible_picture_instance(shape, pkg, image_cache=None):
363
+ """Render one non-background picture exactly as its PPT instance appears."""
364
+ from PIL import Image
365
+
366
+ bounds = shape.get('box') or {}
367
+ source_box = shape.get('box_unrotated') or bounds
368
+ width = max(1, int(round(bounds.get('w') or 0)))
369
+ height = max(1, int(round(bounds.get('h') or 0)))
370
+ if not source_box.get('w') or not source_box.get('h'):
371
+ return None
372
+ local = dict(shape)
373
+ local['box_unrotated'] = {
374
+ 'x': float(source_box.get('x') or 0) - float(bounds.get('x') or 0),
375
+ 'y': float(source_box.get('y') or 0) - float(bounds.get('y') or 0),
376
+ 'w': source_box['w'],
377
+ 'h': source_box['h'],
378
+ }
379
+ canvas = Image.new('RGBA', (width, height))
380
+ if not _draw_picture(canvas, local, pkg, image_cache):
381
+ return None
382
+ return canvas if canvas.getchannel('A').getbbox() else None
383
+
384
+
385
+ def _save_picture_instance_webp(canvas, path):
386
+ """Keep the instance alpha and shrink only after lossless output exceeds budget."""
387
+ def write(options):
388
+ temporary = path + '.tmp'
389
+ try:
390
+ canvas.save(temporary, 'WEBP', **options)
391
+ size = os.path.getsize(temporary)
392
+ os.replace(temporary, path)
393
+ return size
394
+ finally:
395
+ if os.path.exists(temporary):
396
+ os.unlink(temporary)
397
+
398
+ try:
399
+ size = write({'lossless': True, 'method': 0})
400
+ except Exception as exc:
401
+ return 'pillow-error: %s: %s' % (exc.__class__.__name__, exc)
402
+ if size <= ASSET_BUDGET_BYTES:
403
+ return None
404
+ for quality in (95, 90, 85, 75):
405
+ try:
406
+ size = write({'quality': quality, 'method': 6})
407
+ except Exception:
408
+ return None
409
+ if size <= ASSET_BUDGET_BYTES:
410
+ return None
411
+ return None
412
+
413
+
414
+ def export_visible_picture_instances(pkg, shapes, outdir, pillow_ok, blocked=None):
415
+ """Replace transformed local pictures with rendered media consumers can reuse.
416
+
417
+ A raw media file cannot reproduce an instance-level crop, rotation, flip, or
418
+ opacity. Flattening only those non-fullscreen instances keeps the downstream
419
+ contract simple: every asset still resolves through ordinary ``source_media``.
420
+ """
421
+ if not pillow_ok:
422
+ return []
423
+ try:
424
+ from PIL import Image # noqa: F401
425
+ except Exception:
426
+ return []
427
+
428
+ media_dir = os.path.join(outdir, 'media-out')
429
+ os.makedirs(media_dir, exist_ok=True)
430
+ blocked = blocked if blocked is not None else {}
431
+ rows_by_media, rendered_specs, image_cache = {}, {}, {}
432
+ for shape in shapes:
433
+ if (shape.get('kind') != 'pic' or shape.get('hidden')
434
+ or not shape.get('media') or shape.get('media') not in pkg.names
435
+ or _is_full_canvas_picture(shape) or not _picture_has_appearance(shape)):
436
+ continue
437
+ source = shape['media']
438
+ source_box = shape.get('box_unrotated') or shape.get('box') or {}
439
+ visible_box = shape.get('box') or {}
440
+ spec = json.dumps({
441
+ 'source': source,
442
+ 'source_size': [source_box.get('w'), source_box.get('h')],
443
+ 'visible_size': [visible_box.get('w'), visible_box.get('h')],
444
+ 'crop': shape.get('crop') or {},
445
+ 'flipH': bool(shape.get('flipH')),
446
+ 'flipV': bool(shape.get('flipV')),
447
+ 'rot': shape.get('rot') or 0,
448
+ 'opacity': shape.get('opacity', 1.0),
449
+ }, ensure_ascii=False, sort_keys=True)
450
+ media = rendered_specs.get(spec)
451
+ if media is None:
452
+ canvas = _visible_picture_instance(shape, pkg, image_cache)
453
+ if canvas is None:
454
+ continue
455
+ digest = hashlib.sha256(spec.encode('utf-8')).hexdigest()[:16]
456
+ media = 'generated/instance/picture-%s.webp' % digest
457
+ out = 'media-out/' + os.path.basename(media)
458
+ path = os.path.join(outdir, out)
459
+ if not os.path.exists(path):
460
+ reason = _save_picture_instance_webp(canvas, path)
461
+ if reason:
462
+ row = blocked.setdefault(source, {
463
+ 'reason': reason, 'attempted_specs': 0, 'part_refs': [],
464
+ })
465
+ row['attempted_specs'] += 1
466
+ row['part_refs'].append(shape.get('part'))
467
+ shape['visible_instance_blocked'] = reason
468
+ rendered_specs[spec] = False
469
+ continue
470
+ rendered_specs[spec] = media
471
+ rows_by_media[media] = {
472
+ 'media': media,
473
+ 'ext': 'webp',
474
+ 'bytes': os.path.getsize(path),
475
+ 'used_n': 0,
476
+ 'reasons': ['visible_picture_instance'],
477
+ 'candidate': True,
478
+ 'exported': True,
479
+ 'out': out,
480
+ 'out_bytes': os.path.getsize(path),
481
+ 'transcoded': False,
482
+ 'generated': True,
483
+ 'rendered_from': source,
484
+ 'rendered_with': {
485
+ key: shape[key] for key in ('crop', 'flipH', 'flipV', 'rot', 'opacity')
486
+ if shape.get(key) not in (None, False, {}, 0, 1.0)
487
+ },
488
+ 'part_refs': [],
489
+ }
490
+ elif media is False:
491
+ blocked[source]['part_refs'].append(shape.get('part'))
492
+ shape['visible_instance_blocked'] = blocked[source]['reason']
493
+ continue
494
+ row = rows_by_media[media]
495
+ row['used_n'] += 1
496
+ row['part_refs'].append(shape.get('part'))
497
+ shape['media'] = media
498
+ shape.pop('media_svg', None)
499
+ shape['rendered_from'] = source
500
+ return list(rows_by_media.values())
501
+
502
+
336
503
  def _draw_background(canvas, background, pkg, image_cache=None):
337
504
  """Draw the effective p:bg underneath picture layers."""
338
505
  from PIL import Image
@@ -353,6 +520,48 @@ def _draw_background(canvas, background, pkg, image_cache=None):
353
520
  }, pkg, image_cache)
354
521
 
355
522
 
523
+ def _draw_background_fill_overlay(canvas, shape):
524
+ """Draw one full-canvas fill shape that visually modifies a background image."""
525
+ from PIL import Image
526
+ from render_pages import _grad_image, _grad_stops, _rgba, css_color, css_gradient
527
+
528
+ fill = shape.get('fill') or {}
529
+ box = shape.get('box_unrotated') or shape.get('box') or {}
530
+ width = max(1, int(round(box.get('w') or 0)))
531
+ height = max(1, int(round(box.get('h') or 0)))
532
+ x = int(round(box.get('x') or 0))
533
+ y = int(round(box.get('y') or 0))
534
+ if fill.get('type') == 'solid':
535
+ color = _rgba(css_color(fill.get('color')))
536
+ if color is None:
537
+ return False
538
+ overlay = Image.new('RGBA', (width, height), color)
539
+ elif fill.get('type') == 'gradient':
540
+ gradient = _grad_stops(css_gradient(fill) or '')
541
+ if not gradient:
542
+ return False
543
+ overlay = _grad_image(Image, width, height, gradient[0], gradient[1])
544
+ else:
545
+ return False
546
+ canvas.alpha_composite(overlay, (x, y))
547
+ return True
548
+
549
+
550
+ def _background_layer_source(shape):
551
+ if shape.get('kind') == 'pic':
552
+ return {
553
+ 'part': shape['part'], 'kind': 'picture', 'media': shape['media'],
554
+ 'crop': shape.get('crop'), 'flipH': bool(shape.get('flipH')),
555
+ 'flipV': bool(shape.get('flipV')), 'rot': shape.get('rot') or 0,
556
+ 'opacity': shape.get('opacity', 1.0),
557
+ }
558
+ return {
559
+ 'part': shape['part'], 'kind': 'fill_overlay',
560
+ 'box': shape.get('box_unrotated') or shape.get('box'),
561
+ 'fill': shape.get('fill'),
562
+ }
563
+
564
+
356
565
  def _save_background_webp(canvas, path):
357
566
  """Prefer lossless output; fall back to a quality ladder when oversized."""
358
567
  canvas.convert('RGB').save(path, 'WEBP', lossless=True, method=0)
@@ -398,15 +607,12 @@ def compose_backgrounds(pkg, graph, shapes, bg_by_part, units, outdir, pillow_ok
398
607
  image_cache = {}
399
608
  for target, chain in chains.items():
400
609
  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])):
610
+ if _is_full_canvas_picture(shape) or _is_full_canvas_fill_overlay(shape)]
611
+ pictures = [shape for shape in layers if shape.get('kind') == 'pic']
612
+ if not pictures or (len(pictures) == 1 and not _picture_has_appearance(pictures[0])
613
+ and len(layers) == 1):
403
614
  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]
615
+ source_layers = [_background_layer_source(shape) for shape in layers]
410
616
  spec = json.dumps({
411
617
  'background': _effective_background(chain, bg_by_part),
412
618
  'layers': [{k: v for k, v in layer.items() if k != 'part'}
@@ -417,8 +623,12 @@ def compose_backgrounds(pkg, graph, shapes, bg_by_part, units, outdir, pillow_ok
417
623
  canvas = Image.new('RGBA', (units.w, units.h))
418
624
  _draw_background(
419
625
  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)]
626
+ drawn = [
627
+ shape for shape in layers
628
+ if (_draw_picture(canvas, shape, pkg, image_cache)
629
+ if shape.get('kind') == 'pic'
630
+ else _draw_background_fill_overlay(canvas, shape))
631
+ ]
422
632
  if len(drawn) != len(layers):
423
633
  continue
424
634
  digest = hashlib.sha256(canvas.tobytes()).hexdigest()[:16]
@@ -826,6 +1036,9 @@ def extract(pptx, outdir, export_all=False):
826
1036
  'box_emu': {'x': 0, 'y': 0, 'cx': cx, 'cy': cy},
827
1037
  'crop': bg.get('crop'), 'placement': 'inside',
828
1038
  'w_pct': 100.0, 'h_pct': 100.0, 'in_group': False})
1039
+ instance_blocked = {}
1040
+ instance_media = export_visible_picture_instances(
1041
+ pkg, shapes, outdir, pillow_ok, instance_blocked)
829
1042
  images, variant_groups = image_census(shapes, bg_images, units) # S5
830
1043
  t = mark('S5_image_census', t)
831
1044
 
@@ -845,6 +1058,10 @@ def extract(pptx, outdir, export_all=False):
845
1058
  t = mark('derived_censuses', t)
846
1059
 
847
1060
  media_rows = export_media(pkg, images, outdir, pillow_ok, export_all) # S9
1061
+ for row in media_rows:
1062
+ if row['media'] in instance_blocked:
1063
+ row['visible_instance_blocked'] = instance_blocked[row['media']]
1064
+ media_rows += instance_media
848
1065
  background_composites, composite_media, composite_images = compose_backgrounds(
849
1066
  pkg, graph, shapes, bg_by_part, units, outdir, pillow_ok)
850
1067
  media_rows += composite_media
@@ -949,6 +1166,8 @@ def extract(pptx, outdir, export_all=False):
949
1166
  'content_clusters_multi_media': sum(1 for c in clusters if c['member_n'] > 1),
950
1167
  'media_exported': sum(1 for m in media_rows
951
1168
  if m.get('exported') and not m.get('generated')),
1169
+ 'media_instances': len(instance_media),
1170
+ 'media_instance_blocked': len(instance_blocked),
952
1171
  'media_transcoded': sum(1 for m in media_rows
953
1172
  if m.get('transcoded') and not m.get('generated')),
954
1173
  'media_transcode_blocked': sum(1 for m in media_rows
@@ -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: