@lark-apaas/coding-steering 0.1.32-dev.5abff3b → 0.1.32-dev.6ef2b6f

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.
@@ -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
@@ -299,6 +299,22 @@ NV_TAGS = {'sp': 'p:nvSpPr', 'pic': 'p:nvPicPr', 'grpSp': 'p:nvGrpSpPr',
299
299
  'cxnSp': 'p:nvCxnSpPr', 'graphicFrame': 'p:nvGraphicFramePr'}
300
300
 
301
301
 
302
+ def blip_opacity(blip):
303
+ """Combine the alpha modulation transforms attached to one local picture."""
304
+ opacity = 1.0
305
+ found = False
306
+ for effect in blip:
307
+ if local(effect.tag) not in ('alphaMod', 'alphaModFix'):
308
+ continue
309
+ try:
310
+ amount = int(effect.get('amt'))
311
+ except (TypeError, ValueError):
312
+ continue
313
+ opacity *= max(0.0, min(amount / 100000.0, 1.0))
314
+ found = True
315
+ return round(opacity, 6) if found else None
316
+
317
+
302
318
  def walk_tree(el, ctx, out, path=(), xf=(1.0, 1.0, 0.0, 0.0), depth=0):
303
319
  sx, sy, dx, dy = xf
304
320
  U, W, H = ctx.units, ctx.units.w, ctx.units.h
@@ -393,9 +409,9 @@ def walk_tree(el, ctx, out, path=(), xf=(1.0, 1.0, 0.0, 0.0), depth=0):
393
409
  blip = sp.find('p:blipFill/a:blip', NS)
394
410
  if blip is not None:
395
411
  rec['media'] = ctx.media_of(blip.get(R_EMBED)) or ctx.media_of(blip.get(R_LINK))
396
- alpha = blip.find('a:alphaModFix', NS)
397
- if alpha is not None and alpha.get('amt') is not None:
398
- rec['opacity'] = round(int(alpha.get('amt')) / 100000.0, 6)
412
+ opacity = blip_opacity(blip)
413
+ if opacity is not None:
414
+ rec['opacity'] = opacity
399
415
  svg = blip.find('a:extLst//asvg:svgBlip', NS)
400
416
  if svg is not None:
401
417
  rec['media_svg'] = ctx.media_of(svg.get(R_EMBED))
@@ -88,13 +88,15 @@ def css_gradient(grad):
88
88
  def background_css(bg):
89
89
  """页底 <p:bg> 描述符 → CSS background 值。解析不出返回 None,交上层回退到下一层底色。
90
90
 
91
- bgPr solid / bgRef 都带 `color`;gradient 与形状渐变共用 css_gradient;
92
- 背景图不重建(媒体未必导出),按无底色处理。
91
+ bgPr solid / bgRef 都带 `color`;线性 gradient 与形状渐变共用 css_gradient;
92
+ path 渐变和背景图不重建,按无底色处理,不能伪装成线性渐变。
93
93
  """
94
94
  if not bg:
95
95
  return None
96
96
  kind = bg.get('type')
97
97
  if kind == 'gradient':
98
+ if bg.get('path'):
99
+ return None
98
100
  return css_gradient(bg)
99
101
  if kind in ('image', 'none', 'pattern'):
100
102
  return None
@@ -438,6 +438,119 @@ class AssetJudgmentPackageTest(unittest.TestCase):
438
438
  if result.fails
439
439
  ])
440
440
 
441
+ def test_package_places_a_rendered_picture_instance_as_a_logo_asset(self):
442
+ try:
443
+ from PIL import Image
444
+ except ImportError:
445
+ self.skipTest('Pillow unavailable')
446
+
447
+ with tempfile.TemporaryDirectory() as root:
448
+ stage1 = os.path.join(root, 'stage1')
449
+ lout = os.path.join(stage1, 'l-out')
450
+ pack = os.path.join(root, 'pack')
451
+ media = os.path.join(stage1, 'media-out')
452
+ ref = os.path.join(stage1, 'ref')
453
+ os.makedirs(lout)
454
+ os.makedirs(media)
455
+ os.makedirs(ref)
456
+
457
+ source = 'generated/instance/picture-directory-logo.webp'
458
+ output = 'picture-directory-logo.webp'
459
+ box = [1813, 18, 59, 228]
460
+ Image.new('RGBA', (59, 228), (20, 60, 100, 255)).save(
461
+ os.path.join(media, output), 'WEBP')
462
+ extract = {
463
+ 'source': {'filename': 'fixture.pptx'},
464
+ 'canvas': {
465
+ 'px': [1920, 1080],
466
+ 'source': {'cx': 12192000, 'cy': 6858000, 'unit': 'EMU'},
467
+ },
468
+ 'media': [{
469
+ 'media': source,
470
+ 'out': 'media-out/' + output,
471
+ 'generated': True,
472
+ 'rendered_from': 'ppt/media/original-logo.png',
473
+ }],
474
+ 'images': [{
475
+ 'media': source,
476
+ 'boxes': [{
477
+ 'count': 1,
478
+ 'box': dict(zip(('x', 'y', 'w', 'h'), box)),
479
+ 'parts': ['ppt/slides/slide2.xml'],
480
+ }],
481
+ }],
482
+ 'color_freq': [],
483
+ 'text_scale': [],
484
+ }
485
+ with open(os.path.join(stage1, 'extract.json'), 'w', encoding='utf-8') as stream:
486
+ json.dump(extract, stream)
487
+ with open(os.path.join(ref, 'shapes.json'), 'w', encoding='utf-8') as stream:
488
+ json.dump({'shapes': [{
489
+ 'part': 'ppt/slides/slide2.xml',
490
+ 'kind': 'pic',
491
+ 'box': dict(zip(('x', 'y', 'w', 'h'), box)),
492
+ }]}, stream)
493
+ with open(os.path.join(lout, 'asset-vision-groups.json'),
494
+ 'w', encoding='utf-8') as stream:
495
+ json.dump({
496
+ 'version': 2,
497
+ 'selected': [{
498
+ 'candidates': [{
499
+ 'id': 'asset-directory-logo',
500
+ 'source_media': output,
501
+ 'placements': [{
502
+ 'slide': 2,
503
+ 'archetype': 'content',
504
+ 'box': box,
505
+ }],
506
+ }],
507
+ }],
508
+ 'omitted': [],
509
+ }, stream)
510
+ with open(os.path.join(lout, 'manifest.yaml'), 'w', encoding='utf-8') as stream:
511
+ stream.write(
512
+ 'version: alpha\n'
513
+ 'name: rendered-instance-fixture\n'
514
+ 'description: Rendered local PPT image instances remain reusable assets.\n'
515
+ 'asset_vision_groups:\n'
516
+ ' - id: asset-directory-logo\n'
517
+ ' source_media: picture-directory-logo.webp\n'
518
+ ' box: [1813, 18, 59, 228]\n'
519
+ ' visual_kind: logo\n'
520
+ )
521
+ for name, content in (
522
+ ('frontmatter.yaml', ''),
523
+ ('body.md', 'Read `layouts.md` before composing a slide.\n\n{{ASSET_TABLE}}\n'),
524
+ ('layouts.yaml',
525
+ 'layouts:\n'
526
+ ' content:\n'
527
+ ' name: "目录"\n'
528
+ ' role: content\n'
529
+ ' slots:\n'
530
+ ' - {role: asset-candidate, box: [1813, 18, 59, 228], type: pic, '
531
+ 'source_media: picture-directory-logo.webp}\n'
532
+ ' confidence: high\n'),
533
+ ):
534
+ with open(os.path.join(lout, name), 'w', encoding='utf-8') as stream:
535
+ stream.write(content)
536
+ check_v1 = os.path.join(root, 'check_v1.py')
537
+ with open(check_v1, 'w', encoding='utf-8') as stream:
538
+ stream.write('raise SystemExit(0)\n')
539
+
540
+ self.assertEqual(package_main([stage1, lout, pack, '--check-v1', check_v1]), 0)
541
+
542
+ with open(os.path.join(pack, 'design.md'), encoding='utf-8') as stream:
543
+ design = stream.read()
544
+ with open(os.path.join(pack, 'layouts.md'), encoding='utf-8') as stream:
545
+ layouts = stream.read()
546
+ self.assertTrue(os.path.isfile(os.path.join(pack, 'assets', 'logos', '1.webp')))
547
+ self.assertIn('logo-1:', design)
548
+ self.assertIn('assets/logos/1.webp', design)
549
+ self.assertIn(
550
+ '- {role: logo, box: [1813, 18, 59, 228], type: pic, asset: logo-1}',
551
+ layouts,
552
+ )
553
+
441
554
 
442
555
  if __name__ == '__main__':
443
556
  unittest.main()