@lark-apaas/coding-steering 0.1.32-dev.4f80f68 → 0.1.32-dev.942e73f
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.
- package/package.json +1 -1
- package/steering/design-html/skills/pptx-style-extract/SKILL.md +27 -18
- package/steering/design-html/skills/pptx-style-extract/scripts/census.py +8 -2
- package/steering/design-html/skills/pptx-style-extract/scripts/draft.py +1152 -178
- package/steering/design-html/skills/pptx-style-extract/scripts/extract.py +229 -10
- package/steering/design-html/skills/pptx-style-extract/scripts/ooxml.py +18 -1
- package/steering/design-html/skills/pptx-style-extract/scripts/package.py +643 -40
- package/steering/design-html/skills/pptx-style-extract/scripts/parts.py +19 -3
- package/steering/design-html/skills/pptx-style-extract/scripts/render_pages.py +4 -2
- package/steering/design-html/skills/pptx-style-extract/scripts/test_asset_judgment_package.py +556 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/test_background_composite.py +308 -1
- package/steering/design-html/skills/pptx-style-extract/scripts/test_design_consumer_contract.py +6 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/test_flow_layout_contract.py +157 -7
- package/steering/design-html/skills/pptx-style-extract/scripts/test_layout_css.py +1519 -2
- package/steering/design-html/skills/pptx-style-extract/scripts/test_logo_scope.py +148 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/test_text_role_contract.py +151 -4
- package/steering/design-html/skills/pptx-style-extract/scripts/verify_logo_scope.py +188 -0
- 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
|
-
|
|
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 = [
|
|
421
|
-
|
|
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
|
-
|
|
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:
|