@lark-apaas/coding-steering 0.1.18-dev.4e64c13 → 0.1.18-dev.61b3ece
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/charts/SKILL.md +4 -0
- package/steering/design-html/skills/pptx-style-extract/SKILL.md +11 -8
- package/steering/design-html/skills/pptx-style-extract/scripts/check_v2.py +33 -3
- package/steering/design-html/skills/pptx-style-extract/scripts/draft.py +526 -110
- package/steering/design-html/skills/pptx-style-extract/scripts/extract.py +226 -6
- package/steering/design-html/skills/pptx-style-extract/scripts/ooxml.py +18 -1
- package/steering/design-html/skills/pptx-style-extract/scripts/package.py +167 -28
- package/steering/design-html/skills/pptx-style-extract/scripts/parts.py +3 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/query.py +3 -8
- package/steering/design-html/skills/pptx-style-extract/scripts/test_background_composite.py +57 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/test_color_contract.py +60 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/test_design_consumer_contract.py +63 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/test_flow_layout_contract.py +468 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/test_layout_css.py +127 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/test_rounded_contract.py +112 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/test_text_role_contract.py +208 -0
- package/steering/design-html/skills/pptx-style-extract/v2-format-spec.md +14 -7
- package/steering/design-html/skills/slide-deck/SKILL.md +15 -20
- package/steering/design-html/skills/slide-deck/scripts/check_local_references.py +179 -0
- package/steering/nestjs-react-fullstack/skills/plugin-guide/SKILL.md +5 -3
- package/steering/nestjs-react-fullstack/skills_local/plugin-guide/SKILL.md +4 -0
- package/steering/vite-react/skills/plugin-guide/SKILL.md +3 -1
- package/steering/vite-react/skills/react-three-fiber/SKILL.md +4 -0
|
@@ -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
|
|
@@ -251,6 +253,214 @@ def export_media(pkg, images, outdir, pillow_ok, export_all=False):
|
|
|
251
253
|
return rows
|
|
252
254
|
|
|
253
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
|
+
|
|
254
464
|
# ---------------------------------------------------------------- form hint
|
|
255
465
|
# PowerPoint 出厂版式名(中英两套)。设计师起的名字是「这一页干什么用」,
|
|
256
466
|
# 出厂名只是「这个占位符组合叫什么」——后者不算模板声明了页型。
|
|
@@ -635,11 +845,16 @@ def extract(pptx, outdir, export_all=False):
|
|
|
635
845
|
t = mark('derived_censuses', t)
|
|
636
846
|
|
|
637
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
|
|
638
852
|
t = mark('S9_media_export', t)
|
|
639
853
|
|
|
640
854
|
# S5b + S14 run after S9 because the palette only covers exported assets.
|
|
641
|
-
|
|
642
|
-
|
|
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)
|
|
643
858
|
exported = {m['media'] for m in media_rows if m.get('exported')}
|
|
644
859
|
image_palette(images, fps, exported)
|
|
645
860
|
t = mark('S5b_S14_content_palette', t)
|
|
@@ -711,6 +926,7 @@ def extract(pptx, outdir, export_all=False):
|
|
|
711
926
|
'Pillow unavailable: byte-identical media only, no perceptual merging'),
|
|
712
927
|
'palette_available': pillow_ok,
|
|
713
928
|
'media': media_rows,
|
|
929
|
+
'background_composites': background_composites,
|
|
714
930
|
# S4 shape-facts dominate this file (roughly half its bytes) and stage 2 reads
|
|
715
931
|
# them only when a derived statistic needs backing evidence, so they live in
|
|
716
932
|
# a sidecar and extract.json keeps just the pointer plus the derived censuses.
|
|
@@ -721,6 +937,7 @@ def extract(pptx, outdir, export_all=False):
|
|
|
721
937
|
'slides': len(pkg.slides), 'layouts': len(pkg.layouts),
|
|
722
938
|
'masters': len(pkg.masters), 'themes': len(pkg.themes),
|
|
723
939
|
'media': len(pkg.media),
|
|
940
|
+
'background_composites': len(composite_media),
|
|
724
941
|
'shapes_total': len(shapes),
|
|
725
942
|
'shapes_kept': len(kept_shapes),
|
|
726
943
|
'shapes_dropped_off_canvas': len(dropped_shapes),
|
|
@@ -730,8 +947,10 @@ def extract(pptx, outdir, export_all=False):
|
|
|
730
947
|
if r.get('placement') == 'inherited'),
|
|
731
948
|
'content_clusters': len(clusters),
|
|
732
949
|
'content_clusters_multi_media': sum(1 for c in clusters if c['member_n'] > 1),
|
|
733
|
-
'media_exported': sum(1 for m in media_rows
|
|
734
|
-
|
|
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')),
|
|
735
954
|
'media_transcode_blocked': sum(1 for m in media_rows
|
|
736
955
|
if m.get('transcode_blocked')),
|
|
737
956
|
'media_over_budget': sum(1 for m in media_rows if m.get('over_budget')),
|
|
@@ -800,9 +1019,10 @@ def extract(pptx, outdir, export_all=False):
|
|
|
800
1019
|
print(' shapes %d kept / %d dropped off-canvas / %d bleed / %d clamped'
|
|
801
1020
|
% (payload['counts']['shapes_kept'], payload['counts']['shapes_dropped_off_canvas'],
|
|
802
1021
|
payload['counts']['shapes_bleed'], payload['counts']['shapes_clamped']))
|
|
803
|
-
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'
|
|
804
1023
|
% (len(color_freq), len(fonts), len(images),
|
|
805
|
-
payload['counts']['media_exported'], len(pkg.media),
|
|
1024
|
+
payload['counts']['media_exported'], len(pkg.media),
|
|
1025
|
+
len(composite_media), len(guides)))
|
|
806
1026
|
print(' ref/source/ 原文 %d 个部件(判断不了时可直接翻)' % src_n)
|
|
807
1027
|
print(' extract.json %.1f KB total %.2fs'
|
|
808
1028
|
% (os.path.getsize(out_json) / 1024.0, perf['total']))
|
|
@@ -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:
|
|
@@ -98,7 +98,8 @@ L 层判断单 schema(<l-out-dir> 四个文件,这段就是填写说明书
|
|
|
98
98
|
role: cover
|
|
99
99
|
background: bg-cover
|
|
100
100
|
slots:
|
|
101
|
-
- {role: title, box: [<x>, <y>, <w>, <h>], type: title
|
|
101
|
+
- {role: title, box: [<x>, <y>, <w>, <h>], type: title,
|
|
102
|
+
css: "<由源模板转译出的 CSS 声明串>"}
|
|
102
103
|
confidence: high
|
|
103
104
|
|
|
104
105
|
可选 `body:` 块标量 —— 追加到 layouts.md frontmatter 之后作为说明正文。
|
|
@@ -274,6 +275,10 @@ def truthy(v):
|
|
|
274
275
|
return str(v).strip().lower() in ('1', 'true', 'yes', 'on')
|
|
275
276
|
|
|
276
277
|
|
|
278
|
+
# 浏览器能解码的图片格式。落进包的资产必须在此列,否则消费端引用到就是一张空白图。
|
|
279
|
+
WEB_SAFE_EXT = {'png', 'jpg', 'jpeg', 'webp', 'gif', 'svg', 'avif'}
|
|
280
|
+
|
|
281
|
+
|
|
277
282
|
def asset_ext(name):
|
|
278
283
|
return name.rsplit('.', 1)[-1].lower() if '.' in name else ''
|
|
279
284
|
|
|
@@ -353,12 +358,25 @@ def place_assets(manifest, extract, stage1, pack):
|
|
|
353
358
|
entry['path'] = 'assets/%ss/%s.%s' % (kind, base, cext)
|
|
354
359
|
if truthy(a.get('use_full')):
|
|
355
360
|
oext = asset_ext(row['out'])
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
361
|
+
if oext.lower() in WEB_SAFE_EXT:
|
|
362
|
+
fdst = os.path.join(sub, '%s@full.%s' % (base, oext))
|
|
363
|
+
shutil.copy2(orig_path, fdst)
|
|
364
|
+
copied.append(fdst)
|
|
365
|
+
entry['full'] = 'assets/%ss/%s@full.%s' % (kind, base, oext)
|
|
366
|
+
else:
|
|
367
|
+
# 原图是浏览器不解码的格式(tiff/bmp 之类)。落进包并在 design.md
|
|
368
|
+
# 里声明成可用资源,消费端引用它就是一张空白图——实测踩过一次,
|
|
369
|
+
# 封面与结束页因此空白。压缩版已经带着全部像素,full 不给。
|
|
370
|
+
print(' ⚠ %s 的原图是 .%s,浏览器不解码,只给压缩版' % (aid, oext))
|
|
360
371
|
else:
|
|
361
372
|
oext = asset_ext(row['out'])
|
|
373
|
+
if oext.lower() not in WEB_SAFE_EXT:
|
|
374
|
+
# 转码两条路(Pillow / sips)都没成,原图又是浏览器不解码的格式。
|
|
375
|
+
# 照落进去就是把一张永远显示不出来的图当资产下发——实测封面因此空白。
|
|
376
|
+
raise Fail('%s: 原图是 .%s,浏览器不解码,而转码没有产物'
|
|
377
|
+
'(extract.json 里看 transcode_blocked 的原因)。'
|
|
378
|
+
'装上 Pillow 重跑抽取,或把这条资产从 manifest 删掉'
|
|
379
|
+
'并在 gaps 写明。' % (aid, oext))
|
|
362
380
|
dst = os.path.join(sub, '%s.%s' % (base, oext))
|
|
363
381
|
shutil.copy2(orig_path, dst)
|
|
364
382
|
copied.append(dst)
|
|
@@ -490,7 +508,7 @@ def render_assets_block(consumer):
|
|
|
490
508
|
|
|
491
509
|
# ------------------------------------------------- 数值可追溯机检(抄错即 FAIL)
|
|
492
510
|
HEX_RE = re.compile(r'#([0-9A-Fa-f]{6})\b')
|
|
493
|
-
FONTSIZE_RE = re.compile(r'\
|
|
511
|
+
FONTSIZE_RE = re.compile(r'\b(?:fontSize|font-size):\s*([\d.]+)px')
|
|
494
512
|
BOX_RE = re.compile(r'\bbox:\s*\[\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*,'
|
|
495
513
|
r'\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*\]')
|
|
496
514
|
KEYLINE_RE = re.compile(r'^(\s*)-?\s*([A-Za-z_][\w-]*):\s*(.*)$')
|
|
@@ -885,6 +903,64 @@ def build_design(manifest, l_frontmatter, consumer, body, has_sidecar, canvas):
|
|
|
885
903
|
return text
|
|
886
904
|
|
|
887
905
|
|
|
906
|
+
def layout_forms(lines):
|
|
907
|
+
"""每个页型草案里实际给了哪几种形态(flow / slots)。"""
|
|
908
|
+
out, cur = {}, None
|
|
909
|
+
for line in lines:
|
|
910
|
+
m = re.match(r'^ ([\w-]+):\s*$', line)
|
|
911
|
+
if m:
|
|
912
|
+
cur = m.group(1)
|
|
913
|
+
out[cur] = set()
|
|
914
|
+
continue
|
|
915
|
+
m = re.match(r'^ (flow|slots):\s*$', line)
|
|
916
|
+
if m and cur:
|
|
917
|
+
out[cur].add(m.group(1))
|
|
918
|
+
return out
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
def layout_modes(lines):
|
|
922
|
+
"""读取扁平 `layout_modes:` 判断区。"""
|
|
923
|
+
out = {}
|
|
924
|
+
for line in lines:
|
|
925
|
+
m = re.match(r'^\s{2}([\w-]+):\s*([^\s#]+)', line)
|
|
926
|
+
if m:
|
|
927
|
+
out[m.group(1)] = m.group(2).strip()
|
|
928
|
+
return out
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
def text_role_boxes(lines):
|
|
932
|
+
"""从 slots 里的同名标记取回文本槽坐标,供 flow 固定锚点复用。"""
|
|
933
|
+
out, pending = {}, None
|
|
934
|
+
for line in lines:
|
|
935
|
+
marker = re.match(r'^\s*#\s*text-role:\s*([\w-]+)\s*$', line)
|
|
936
|
+
if marker:
|
|
937
|
+
pending = marker.group(1)
|
|
938
|
+
continue
|
|
939
|
+
if pending:
|
|
940
|
+
box = re.search(r'\bbox:\s*(\[[^\]]+\])', line)
|
|
941
|
+
if box:
|
|
942
|
+
out[pending] = box.group(1)
|
|
943
|
+
pending = None
|
|
944
|
+
return out
|
|
945
|
+
|
|
946
|
+
|
|
947
|
+
def select_layout_forms(lines, modes):
|
|
948
|
+
"""按 `layout_modes` 只保留每个页型选中的 flow 或 slots。"""
|
|
949
|
+
out, layout, drop = [], None, False
|
|
950
|
+
for line in lines:
|
|
951
|
+
layout_match = re.match(r'^ ([\w-]+):\s*$', line)
|
|
952
|
+
form_match = re.match(r'^ (flow|slots):\s*$', line)
|
|
953
|
+
if layout_match:
|
|
954
|
+
layout, drop = layout_match.group(1), False
|
|
955
|
+
elif form_match:
|
|
956
|
+
drop = modes.get(layout) not in (None, form_match.group(1))
|
|
957
|
+
elif drop and re.match(r'^ \S', line):
|
|
958
|
+
drop = False
|
|
959
|
+
if not drop:
|
|
960
|
+
out.append(line)
|
|
961
|
+
return out
|
|
962
|
+
|
|
963
|
+
|
|
888
964
|
def shrink_safe_area(lines):
|
|
889
965
|
"""text_safe 与 avoid 相交时把安全区收掉重叠的那部分。
|
|
890
966
|
|
|
@@ -943,8 +1019,8 @@ def build_layouts_md(layouts_blocks, canvas):
|
|
|
943
1019
|
raise Fail('layouts.yaml 不要写 canvas —— 脚本从 extract.json 取')
|
|
944
1020
|
if 'layouts' not in blocks:
|
|
945
1021
|
raise Fail('layouts.yaml 缺顶层键 `layouts:`')
|
|
946
|
-
# `names:` / `bg_rules:` 是给 L
|
|
947
|
-
# archetype,本身不进产物。让 L 层只改扁平键值,别去动 layouts 里的
|
|
1022
|
+
# `names:` / `roles:` / `text_roles:` / `layout_modes:` / `bg_rules:` 是给 L 层集中填判断的
|
|
1023
|
+
# 扁平区——在这里并回各 archetype,本身不进产物。让 L 层只改扁平键值,别去动 layouts 里的
|
|
948
1024
|
# slots/confidence 结构(嵌套结构手改极易破坏缩进,进而静默改变语义)。
|
|
949
1025
|
names, roles = {}, {}
|
|
950
1026
|
for key, sink in (('names', names), ('roles', roles)):
|
|
@@ -952,6 +1028,17 @@ def build_layouts_md(layouts_blocks, canvas):
|
|
|
952
1028
|
m = re.match(r'^\s{2}([\w-]+):\s*(.+?)\s*$', line)
|
|
953
1029
|
if m:
|
|
954
1030
|
sink[m.group(1)] = unquote(m.group(2))
|
|
1031
|
+
text_roles = {}
|
|
1032
|
+
allowed_text_roles = {'title', 'subtitle', 'header', 'footer', 'body'}
|
|
1033
|
+
for line in blocks.get('text_roles', ('', []))[1]:
|
|
1034
|
+
m = re.match(r'^\s{2}([\w-]+):\s*([A-Za-z-]+)(?:\s+#.*)?$', line)
|
|
1035
|
+
if not m:
|
|
1036
|
+
continue
|
|
1037
|
+
role_id, role = m.groups()
|
|
1038
|
+
if role not in allowed_text_roles:
|
|
1039
|
+
raise Fail('text_roles.%s 取值 %s 非法;应为 %s'
|
|
1040
|
+
% (role_id, role, '|'.join(sorted(allowed_text_roles))))
|
|
1041
|
+
text_roles[role_id] = role
|
|
955
1042
|
bg_rules, cur = {}, None
|
|
956
1043
|
for line in blocks.get('bg_rules', ('', []))[1]:
|
|
957
1044
|
# 键后面允许行内注释(草案会标「用它的页型:…」)
|
|
@@ -973,44 +1060,96 @@ def build_layouts_md(layouts_blocks, canvas):
|
|
|
973
1060
|
raise Fail('%s: %s' % (bg, err))
|
|
974
1061
|
out.append(' %s:' % bg)
|
|
975
1062
|
out += lines
|
|
1063
|
+
modes = layout_modes(blocks.get('layout_modes', ('', []))[1])
|
|
976
1064
|
out.append('layouts:')
|
|
977
|
-
|
|
1065
|
+
pending_text_role = None
|
|
1066
|
+
used_text_roles = set()
|
|
1067
|
+
source_layout_lines = blocks['layouts'][1]
|
|
1068
|
+
role_boxes = text_role_boxes(source_layout_lines)
|
|
1069
|
+
layout_lines = select_layout_forms(source_layout_lines, modes)
|
|
1070
|
+
cur, fixed_items = None, []
|
|
1071
|
+
|
|
1072
|
+
def flush_fixed_items():
|
|
1073
|
+
if not fixed_items:
|
|
1074
|
+
return
|
|
1075
|
+
out.append(' - kind: free')
|
|
1076
|
+
out.append(' items:')
|
|
1077
|
+
out.extend(fixed_items)
|
|
1078
|
+
fixed_items.clear()
|
|
1079
|
+
|
|
1080
|
+
for line in layout_lines:
|
|
1081
|
+
m = re.match(r'^ ([\w-]+):\s*$', line)
|
|
1082
|
+
if m:
|
|
1083
|
+
flush_fixed_items()
|
|
1084
|
+
cur = m.group(1)
|
|
1085
|
+
elif fixed_items and re.match(r'^ \S', line):
|
|
1086
|
+
flush_fixed_items()
|
|
978
1087
|
# 判断单里的结构事实(栅格、间距序列、样张字数、命中配方)是给 L 层判断用的,
|
|
979
|
-
# 不进产物——消费端要的是结论,不是推导过程。
|
|
1088
|
+
# 不进产物——消费端要的是结论,不是推导过程。layout_mode 同理,它是判断的载体。
|
|
980
1089
|
if line.lstrip().startswith('#'):
|
|
1090
|
+
marker = re.match(r'^\s*#\s*text-role:\s*([\w-]+)\s*$', line)
|
|
1091
|
+
if marker:
|
|
1092
|
+
pending_text_role = marker.group(1)
|
|
981
1093
|
continue
|
|
1094
|
+
if pending_text_role:
|
|
1095
|
+
if pending_text_role not in text_roles:
|
|
1096
|
+
raise Fail('text_roles 缺少 %s 的判断' % pending_text_role)
|
|
1097
|
+
role = text_roles[pending_text_role]
|
|
1098
|
+
slot_type = role if role in ('title', 'subtitle', 'header', 'footer') else 'body'
|
|
1099
|
+
line, role_n = re.subn(r'(\{\s*role:\s*)[\w-]+',
|
|
1100
|
+
r'\g<1>%s' % role, line, count=1)
|
|
1101
|
+
line, type_n = re.subn(r'(\btype:\s*)[\w-]+',
|
|
1102
|
+
r'\g<1>%s' % slot_type, line, count=1)
|
|
1103
|
+
if role_n != 1 or type_n != 1:
|
|
1104
|
+
raise Fail('text_roles.%s 没有命中一个文本槽' % pending_text_role)
|
|
1105
|
+
if (modes.get(cur) == 'flow' and role in ('header', 'footer')
|
|
1106
|
+
and re.match(r'^\s{12}-\s*\{', line) and 'box:' not in line):
|
|
1107
|
+
box = role_boxes.get(pending_text_role)
|
|
1108
|
+
if not box:
|
|
1109
|
+
raise Fail('text_roles.%s 识别为 %s,但 slots 中没有坐标'
|
|
1110
|
+
% (pending_text_role, role))
|
|
1111
|
+
line = re.sub(r',\s*type:', ', box: %s, type:' % box, line, count=1)
|
|
1112
|
+
fixed_items.append(line)
|
|
1113
|
+
used_text_roles.add(pending_text_role)
|
|
1114
|
+
pending_text_role = None
|
|
1115
|
+
continue
|
|
1116
|
+
used_text_roles.add(pending_text_role)
|
|
1117
|
+
pending_text_role = None
|
|
982
1118
|
out.append(line)
|
|
983
|
-
m = re.match(r'^ ([\w-]+):\s*$', line)
|
|
984
1119
|
if m and m.group(1) in names:
|
|
985
1120
|
out.append(' name: "%s"' % names.pop(m.group(1)))
|
|
986
1121
|
if m and m.group(1) in roles:
|
|
987
1122
|
out.append(' role: %s' % roles.pop(m.group(1)))
|
|
1123
|
+
flush_fixed_items()
|
|
988
1124
|
if names:
|
|
989
1125
|
raise Fail('names 里这些页型在 layouts 下找不到:%s' % ', '.join(sorted(names)))
|
|
990
1126
|
if roles:
|
|
991
1127
|
raise Fail('roles 里这些页型在 layouts 下找不到:%s' % ', '.join(sorted(roles)))
|
|
1128
|
+
unused_text_roles = set(text_roles) - used_text_roles
|
|
1129
|
+
if unused_text_roles:
|
|
1130
|
+
raise Fail('text_roles 里这些判断没有命中文本槽:%s'
|
|
1131
|
+
% ', '.join(sorted(unused_text_roles)))
|
|
992
1132
|
missing_role = [k for k in re.findall(r'^ ([\w-]+):\s*$', '\n'.join(blocks['layouts'][1]), re.M)
|
|
993
1133
|
if not re.search(r'^ %s:\s*$(?:\n(?! \S).*)*?\n role:' % re.escape(k),
|
|
994
1134
|
'\n'.join(out), re.M)]
|
|
995
1135
|
if missing_role:
|
|
996
1136
|
raise Fail('这些页型没有 role(在 layouts.yaml 的 roles 段填):%s' % ', '.join(missing_role))
|
|
997
|
-
#
|
|
998
|
-
#
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
if
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
raise Fail('这些页型同时留了 flow 和 slots,二选一删掉另一个:%s' % ', '.join(both))
|
|
1137
|
+
# `layout_modes.*: TODO` 由上面那道通用 TODO 扫描报(它连行内提示一起打出来),
|
|
1138
|
+
# 这里只管它管不到的两种:整行被删、以及填了 flow/slots 之外的值。
|
|
1139
|
+
forms = layout_forms(blocks['layouts'][1])
|
|
1140
|
+
bad = []
|
|
1141
|
+
for k, v in sorted(modes.items()):
|
|
1142
|
+
if len(forms.get(k) or ()) < 2:
|
|
1143
|
+
continue # 只有一份形态,无从选择
|
|
1144
|
+
if v not in ('flow', 'slots'):
|
|
1145
|
+
bad.append('%s 的 layout_modes 判断是 %r' % (k, v))
|
|
1146
|
+
elif v not in forms[k]:
|
|
1147
|
+
bad.append('%s 选了 %s 但该页型没有这一份' % (k, v))
|
|
1148
|
+
for k, forms_for_layout in sorted(forms.items()):
|
|
1149
|
+
if len(forms_for_layout) >= 2 and k not in modes:
|
|
1150
|
+
bad.append('%s 缺 layout_modes 判断' % k)
|
|
1151
|
+
if bad:
|
|
1152
|
+
raise Fail('layout_modes 只能填 flow 或 slots,一个页型一个词:%s' % ';'.join(bad))
|
|
1014
1153
|
declared = set(re.findall(r'^ background:\s*(\S+)\s*$',
|
|
1015
1154
|
'\n'.join(blocks['layouts'][1]), re.M))
|
|
1016
1155
|
stray = set(bg_rules) - declared
|
|
@@ -393,6 +393,9 @@ def walk_tree(el, ctx, out, path=(), xf=(1.0, 1.0, 0.0, 0.0), depth=0):
|
|
|
393
393
|
blip = sp.find('p:blipFill/a:blip', NS)
|
|
394
394
|
if blip is not None:
|
|
395
395
|
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)
|
|
396
399
|
svg = blip.find('a:extLst//asvg:svgBlip', NS)
|
|
397
400
|
if svg is not None:
|
|
398
401
|
rec['media_svg'] = ctx.media_of(svg.get(R_EMBED))
|
|
@@ -244,14 +244,10 @@ def _recipe_css(fill, line, radii, effects):
|
|
|
244
244
|
dash = (line or {}).get('dash')
|
|
245
245
|
style = 'dashed' if dash and 'dash' in dash else 'solid'
|
|
246
246
|
out.append('border: %dpx %s %s' % (w, style, lc))
|
|
247
|
-
if radii:
|
|
247
|
+
if radii and all(r >= 1 for r in radii):
|
|
248
248
|
lo, hi = min(radii), max(radii)
|
|
249
249
|
if abs(hi - lo) <= 0.5:
|
|
250
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
251
|
for e in effects or []:
|
|
256
252
|
if e.get('type') == 'outerShdw':
|
|
257
253
|
col = _css_color(e.get('color')) or 'rgba(0,0,0,0.25)'
|
|
@@ -267,7 +263,7 @@ def _recipe_css(fill, line, radii, effects):
|
|
|
267
263
|
def _sig(fill, line, effects):
|
|
268
264
|
"""分组键 = 填充 + 描边 + 效果。**不含圆角**——OOXML 圆角是 min(w,h) 的百分比,
|
|
269
265
|
同一配方在不同尺寸的卡上绝对 px 必然不同,把它计入键会把一个配方拆成多组;
|
|
270
|
-
|
|
266
|
+
只有组内每个形状都明确共享同一绝对半径时才输出组级圆角,否则留给逐形状 CSS。"""
|
|
271
267
|
f = 'none'
|
|
272
268
|
if isinstance(fill, dict):
|
|
273
269
|
if fill.get('type') == 'solid':
|
|
@@ -305,8 +301,7 @@ def cmd_recipes(a, outdir):
|
|
|
305
301
|
g = groups.setdefault(k, {'n': 0, 'parts': Counter(), 'sizes': [], 'radii': [],
|
|
306
302
|
'fill': fill, 'line': line, 'fx': fx})
|
|
307
303
|
g['n'] += 1
|
|
308
|
-
|
|
309
|
-
g['radii'].append(radius)
|
|
304
|
+
g['radii'].append(radius or 0)
|
|
310
305
|
g['parts'][short(s['part'])] += 1
|
|
311
306
|
b = s.get('box') or {}
|
|
312
307
|
if b.get('w'):
|