@lark-apaas/coding-steering 0.1.18-dev.857e860 → 0.1.18-dev.9dbf919
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 +40 -18
- package/steering/design-html/skills/pptx-style-extract/font-fallback.yaml +3 -3
- package/steering/design-html/skills/pptx-style-extract/scripts/census.py +18 -12
- package/steering/design-html/skills/pptx-style-extract/scripts/check_v2.py +153 -8
- package/steering/design-html/skills/pptx-style-extract/scripts/draft.py +973 -182
- package/steering/design-html/skills/pptx-style-extract/scripts/extract.py +319 -23
- package/steering/design-html/skills/pptx-style-extract/scripts/ooxml.py +1 -1
- package/steering/design-html/skills/pptx-style-extract/scripts/package.py +165 -19
- package/steering/design-html/skills/pptx-style-extract/scripts/parts.py +6 -3
- package/steering/design-html/skills/pptx-style-extract/scripts/query.py +2 -2
- package/steering/design-html/skills/pptx-style-extract/scripts/render_pages.py +14 -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_layout_css.py +98 -0
- package/steering/design-html/skills/pptx-style-extract/v2-format-spec.md +19 -12
- package/steering/design-html/skills/slide-deck/SKILL.md +18 -3
- package/steering/design-html/skills/slide-deck/scripts/check_local_references.py +179 -0
|
@@ -38,7 +38,7 @@ L 层判断单 schema(<l-out-dir> 四个文件,这段就是填写说明书
|
|
|
38
38
|
assets: # 可选;无资产包可整段省略
|
|
39
39
|
- id: bg-cover # 必填。命名规则 <kind 前缀>-<语义名>,
|
|
40
40
|
# logo- / slogan- / bg- / texture- / icon-
|
|
41
|
-
source_media:
|
|
41
|
+
source_media: <原图文件名> # media-out/ 里的**原图**文件名(不是压缩版)
|
|
42
42
|
kind: background # logo|slogan|background|texture|icon
|
|
43
43
|
role: cover # background/icon 用;封闭枚举见规范 §2
|
|
44
44
|
theme: dark # 双主题包按需
|
|
@@ -80,9 +80,9 @@ L 层判断单 schema(<l-out-dir> 四个文件,这段就是填写说明书
|
|
|
80
80
|
|
|
81
81
|
colors:
|
|
82
82
|
surface: "#FFFFFF"
|
|
83
|
-
primary: "#
|
|
83
|
+
primary: "#RRGGBB"
|
|
84
84
|
safe-area:
|
|
85
|
-
content: {top:
|
|
85
|
+
content: {top: <px>, right: <px>, bottom: <px>, left: <px>, applies-to: [content]}
|
|
86
86
|
confidence: medium
|
|
87
87
|
|
|
88
88
|
--------------------------------------------------------------------------------
|
|
@@ -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: [
|
|
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 之后作为说明正文。
|
|
@@ -141,8 +142,8 @@ KIND_PREFIX = {'background': 'bg', 'logo': 'logo', 'slogan': 'slogan',
|
|
|
141
142
|
'texture': 'texture', 'icon': 'icon'}
|
|
142
143
|
# 交付包的 ref/ 只留 audit.yaml(人复核数值出处用,几 KB)。
|
|
143
144
|
# 频次原表、聚类原始数据、extract.json、重建图、logo 候选图全部留在 stage1 抽取目录,
|
|
144
|
-
#
|
|
145
|
-
#
|
|
145
|
+
# 不拷进包——规范原本指望「下发时链路剥离」,但链路上没有环节真的剥离,结果
|
|
146
|
+
# 审计材料带着「别读我」一起进消费上下文,还占掉包体的大头。
|
|
146
147
|
REF_CARRY = ()
|
|
147
148
|
|
|
148
149
|
|
|
@@ -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)
|
|
@@ -384,7 +402,7 @@ def place_assets(manifest, extract, stage1, pack):
|
|
|
384
402
|
if boxes:
|
|
385
403
|
au['boxes'] = boxes
|
|
386
404
|
# aspect 取**未取整**的 box —— 从取整后的整数反算会明显偏
|
|
387
|
-
#
|
|
405
|
+
# 先取整再相除,误差会落到小数点后两位,logo 这种细长框尤其明显。
|
|
388
406
|
raw = clusters[0]['box']
|
|
389
407
|
if raw.get('h'):
|
|
390
408
|
au['aspect'] = round(raw['w'] / float(raw['h']), 3)
|
|
@@ -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*(.*)$')
|
|
@@ -633,6 +651,42 @@ def _box_hit(box, idx):
|
|
|
633
651
|
JUDGEMENT_KEYS = frozenset(('avoid', 'text_safe', 'pairing_rule'))
|
|
634
652
|
|
|
635
653
|
|
|
654
|
+
def media_integrity(stage1, extract):
|
|
655
|
+
"""extract.json 记录的尺寸,必须和磁盘上那个文件真实的尺寸一致。
|
|
656
|
+
|
|
657
|
+
这条挡的是「记录全对、文件被顶替」——两个 media 的输出名撞车时,后写的覆盖先写的,
|
|
658
|
+
extract.json 里各自的记录都还是对的,任何只读记录的检查都发现不了;只有把记录和
|
|
659
|
+
文件本身对一遍才抓得到。实测某模板因此把一张 109x109 的图当成了整页背景。
|
|
660
|
+
"""
|
|
661
|
+
try:
|
|
662
|
+
from PIL import Image
|
|
663
|
+
except Exception:
|
|
664
|
+
return [] # 没有 Pillow 就量不了,跳过而不是假装通过
|
|
665
|
+
bad, seen = [], {}
|
|
666
|
+
for m in extract.get('media') or []:
|
|
667
|
+
for key, rel in (('source_px', m.get('out')),
|
|
668
|
+
('compressed_px', m.get('compressed_out'))):
|
|
669
|
+
rec = m.get(key)
|
|
670
|
+
if not rel or not rec:
|
|
671
|
+
continue
|
|
672
|
+
path = os.path.join(stage1, rel)
|
|
673
|
+
if not os.path.exists(path):
|
|
674
|
+
bad.append('%s 的 %s 指向 %s,文件不存在' % (m['media'], key, rel))
|
|
675
|
+
continue
|
|
676
|
+
try:
|
|
677
|
+
with Image.open(path) as im:
|
|
678
|
+
real = list(im.size)
|
|
679
|
+
except Exception:
|
|
680
|
+
continue
|
|
681
|
+
if real != list(rec):
|
|
682
|
+
bad.append('%s 的 %s 记录 %s,但 %s 实际是 %s'
|
|
683
|
+
% (m['media'], key, rec, rel, real))
|
|
684
|
+
if rel in seen and seen[rel] != m['media']:
|
|
685
|
+
bad.append('%s 与 %s 都写到 %s' % (seen[rel], m['media'], rel))
|
|
686
|
+
seen[rel] = m['media']
|
|
687
|
+
return bad
|
|
688
|
+
|
|
689
|
+
|
|
636
690
|
def trace_check(pack, extract, shapes, derived_values, derived_tokens, factor):
|
|
637
691
|
"""产物里每个 hex / 字号 / slot 坐标都必须可追溯到普查值或 derived 声明。"""
|
|
638
692
|
idx = build_trace_index(extract, shapes)
|
|
@@ -849,6 +903,58 @@ def build_design(manifest, l_frontmatter, consumer, body, has_sidecar, canvas):
|
|
|
849
903
|
return text
|
|
850
904
|
|
|
851
905
|
|
|
906
|
+
def shrink_safe_area(lines):
|
|
907
|
+
"""text_safe 与 avoid 相交时把安全区收掉重叠的那部分。
|
|
908
|
+
|
|
909
|
+
text_safe 是脚本按「该背景各页型的槽位并集」算的,而模板里的槽位本身可能就压在
|
|
910
|
+
主视觉上;avoid 是看图的人填的。两者从不同来源来,会直接打架——实测一张背景的
|
|
911
|
+
text_safe 有 34% 落在 avoid 里,消费端按 text_safe 把标题放进了禁放区,正好压在
|
|
912
|
+
背景的山峰主体上。这里以 avoid 为准收缩:能不能放字是看图的人说了算。
|
|
913
|
+
"""
|
|
914
|
+
def parse_box(s):
|
|
915
|
+
m = re.findall(r'-?\d+', s)
|
|
916
|
+
return [int(x) for x in m[:4]] if len(m) >= 4 else None
|
|
917
|
+
|
|
918
|
+
ts_i = ts = None
|
|
919
|
+
avoids = []
|
|
920
|
+
for i, ln in enumerate(lines):
|
|
921
|
+
st = ln.strip()
|
|
922
|
+
if st.startswith('text_safe:'):
|
|
923
|
+
ts_i, ts = i, parse_box(st.split(':', 1)[1].split('#')[0])
|
|
924
|
+
elif st.startswith('avoid:'):
|
|
925
|
+
for chunk in re.findall(r'box:\s*\[[^\]]*\]', st):
|
|
926
|
+
b = parse_box(chunk)
|
|
927
|
+
if b:
|
|
928
|
+
avoids.append(b)
|
|
929
|
+
if ts is None or not avoids:
|
|
930
|
+
return lines, None
|
|
931
|
+
x0, y0, x1, y1 = ts[0], ts[1], ts[0] + ts[2], ts[1] + ts[3]
|
|
932
|
+
for a in avoids:
|
|
933
|
+
ax0, ay0, ax1, ay1 = a[0], a[1], a[0] + a[2], a[1] + a[3]
|
|
934
|
+
if ax1 <= x0 or ax0 >= x1 or ay1 <= y0 or ay0 >= y1:
|
|
935
|
+
continue # 不相交
|
|
936
|
+
# 从被侵占得最少的一边切:优先保留面积最大的剩余矩形
|
|
937
|
+
cands = []
|
|
938
|
+
if ax0 > x0:
|
|
939
|
+
cands.append((x0, y0, ax0, y1))
|
|
940
|
+
if ax1 < x1:
|
|
941
|
+
cands.append((ax1, y0, x1, y1))
|
|
942
|
+
if ay0 > y0:
|
|
943
|
+
cands.append((x0, y0, x1, ay0))
|
|
944
|
+
if ay1 < y1:
|
|
945
|
+
cands.append((x0, ay1, x1, y1))
|
|
946
|
+
if not cands:
|
|
947
|
+
return lines, 'text_safe 被 avoid 完全覆盖,这张背景没有可放文字的区域'
|
|
948
|
+
x0, y0, x1, y1 = max(cands, key=lambda c: (c[2] - c[0]) * (c[3] - c[1]))
|
|
949
|
+
new = [x0, y0, x1 - x0, y1 - y0]
|
|
950
|
+
if new == ts:
|
|
951
|
+
return lines, None
|
|
952
|
+
lines = list(lines)
|
|
953
|
+
lines[ts_i] = ' text_safe: [%d, %d, %d, %d] # 已按 avoid 收缩(原 %s)' % (
|
|
954
|
+
new[0], new[1], new[2], new[3], ts)
|
|
955
|
+
return lines, None
|
|
956
|
+
|
|
957
|
+
|
|
852
958
|
def build_layouts_md(layouts_blocks, canvas):
|
|
853
959
|
blocks = {k: (inline, lines) for k, inline, lines in layouts_blocks}
|
|
854
960
|
if 'canvas' in blocks:
|
|
@@ -857,12 +963,13 @@ def build_layouts_md(layouts_blocks, canvas):
|
|
|
857
963
|
raise Fail('layouts.yaml 缺顶层键 `layouts:`')
|
|
858
964
|
# `names:` / `bg_rules:` 是给 L 层集中填判断的两块扁平区——在这里并回各
|
|
859
965
|
# archetype,本身不进产物。让 L 层只改扁平键值,别去动 layouts 里的
|
|
860
|
-
# slots/confidence
|
|
861
|
-
names = {}
|
|
862
|
-
for
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
966
|
+
# slots/confidence 结构(嵌套结构手改极易破坏缩进,进而静默改变语义)。
|
|
967
|
+
names, roles = {}, {}
|
|
968
|
+
for key, sink in (('names', names), ('roles', roles)):
|
|
969
|
+
for line in blocks.get(key, ('', []))[1]:
|
|
970
|
+
m = re.match(r'^\s{2}([\w-]+):\s*(.+?)\s*$', line)
|
|
971
|
+
if m:
|
|
972
|
+
sink[m.group(1)] = unquote(m.group(2))
|
|
866
973
|
bg_rules, cur = {}, None
|
|
867
974
|
for line in blocks.get('bg_rules', ('', []))[1]:
|
|
868
975
|
# 键后面允许行内注释(草案会标「用它的页型:…」)
|
|
@@ -875,20 +982,53 @@ def build_layouts_md(layouts_blocks, canvas):
|
|
|
875
982
|
bg_rules[cur].append(' ' + line.strip())
|
|
876
983
|
out = ['---', 'canvas: %dx%d' % tuple(canvas)]
|
|
877
984
|
# 禁放区是背景的属性,按背景写一次;页型只留 `background:` 指针。
|
|
878
|
-
#
|
|
985
|
+
# 合并进每个页型会把同一句话按页型数复制 N 遍。
|
|
879
986
|
if bg_rules:
|
|
880
987
|
out.append('backgrounds:')
|
|
881
988
|
for bg, lines in bg_rules.items():
|
|
989
|
+
lines, err = shrink_safe_area(lines)
|
|
990
|
+
if err:
|
|
991
|
+
raise Fail('%s: %s' % (bg, err))
|
|
882
992
|
out.append(' %s:' % bg)
|
|
883
993
|
out += lines
|
|
884
994
|
out.append('layouts:')
|
|
885
995
|
for line in blocks['layouts'][1]:
|
|
996
|
+
# 判断单里的结构事实(栅格、间距序列、样张字数、命中配方)是给 L 层判断用的,
|
|
997
|
+
# 不进产物——消费端要的是结论,不是推导过程。
|
|
998
|
+
if line.lstrip().startswith('#'):
|
|
999
|
+
continue
|
|
886
1000
|
out.append(line)
|
|
887
1001
|
m = re.match(r'^ ([\w-]+):\s*$', line)
|
|
888
1002
|
if m and m.group(1) in names:
|
|
889
1003
|
out.append(' name: "%s"' % names.pop(m.group(1)))
|
|
1004
|
+
if m and m.group(1) in roles:
|
|
1005
|
+
out.append(' role: %s' % roles.pop(m.group(1)))
|
|
890
1006
|
if names:
|
|
891
1007
|
raise Fail('names 里这些页型在 layouts 下找不到:%s' % ', '.join(sorted(names)))
|
|
1008
|
+
if roles:
|
|
1009
|
+
raise Fail('roles 里这些页型在 layouts 下找不到:%s' % ', '.join(sorted(roles)))
|
|
1010
|
+
missing_role = [k for k in re.findall(r'^ ([\w-]+):\s*$', '\n'.join(blocks['layouts'][1]), re.M)
|
|
1011
|
+
if not re.search(r'^ %s:\s*$(?:\n(?! \S).*)*?\n role:' % re.escape(k),
|
|
1012
|
+
'\n'.join(out), re.M)]
|
|
1013
|
+
if missing_role:
|
|
1014
|
+
raise Fail('这些页型没有 role(在 layouts.yaml 的 roles 段填):%s' % ', '.join(missing_role))
|
|
1015
|
+
# flow 与 slots 二选一:两份都留下,消费端不知道该听哪份坐标——design.md 正文与
|
|
1016
|
+
# layouts.md 打架就是这么来的,别重演。
|
|
1017
|
+
both, cur, has = [], None, {}
|
|
1018
|
+
for line in blocks['layouts'][1]:
|
|
1019
|
+
m = re.match(r'^ ([\w-]+):\s*$', line)
|
|
1020
|
+
if m:
|
|
1021
|
+
if cur and has.get('flow') and has.get('slots'):
|
|
1022
|
+
both.append(cur)
|
|
1023
|
+
cur, has = m.group(1), {}
|
|
1024
|
+
continue
|
|
1025
|
+
m = re.match(r'^ (flow|slots):\s*$', line)
|
|
1026
|
+
if m:
|
|
1027
|
+
has[m.group(1)] = True
|
|
1028
|
+
if cur and has.get('flow') and has.get('slots'):
|
|
1029
|
+
both.append(cur)
|
|
1030
|
+
if both:
|
|
1031
|
+
raise Fail('这些页型同时留了 flow 和 slots,二选一删掉另一个:%s' % ', '.join(both))
|
|
892
1032
|
declared = set(re.findall(r'^ background:\s*(\S+)\s*$',
|
|
893
1033
|
'\n'.join(blocks['layouts'][1]), re.M))
|
|
894
1034
|
stray = set(bg_rules) - declared
|
|
@@ -1032,6 +1172,12 @@ def main(argv=None):
|
|
|
1032
1172
|
shapes = json.load(open(sp, encoding='utf-8'))['shapes'] if os.path.exists(sp) else []
|
|
1033
1173
|
problems, checked = trace_check(pack, extract, shapes,
|
|
1034
1174
|
derived_values, derived_tokens, factor)
|
|
1175
|
+
integrity = media_integrity(stage1, extract)
|
|
1176
|
+
if integrity:
|
|
1177
|
+
print('\n--- 媒体产物完整性 ---')
|
|
1178
|
+
for b in integrity:
|
|
1179
|
+
print(' FAIL ' + b)
|
|
1180
|
+
raise Fail('media-out 里的文件和 extract.json 的记录对不上(%d 处)' % len(integrity))
|
|
1035
1181
|
print('\n--- 数值可追溯机检 ---')
|
|
1036
1182
|
print(' 受检 hex %d / fontSize %d / slot box %d;derived 豁免 %d%s%s'
|
|
1037
1183
|
% (checked['hex'], checked['size'], checked['box'], checked['exempt'],
|
|
@@ -39,15 +39,15 @@ class Package:
|
|
|
39
39
|
self.masters = sorted((n for n in self.names if MASTER_RE.match(n)), key=_num)
|
|
40
40
|
self.themes, self.theme_discovery = self._find_themes(ct)
|
|
41
41
|
# zip directory entries ('ppt/media/') must not be counted as assets —
|
|
42
|
-
#
|
|
42
|
+
# they inflate media totals by one.
|
|
43
43
|
self.media = sorted(n for n in self.names
|
|
44
44
|
if n.startswith('ppt/media/') and not n.endswith('/'))
|
|
45
45
|
|
|
46
46
|
def _find_themes(self, ct):
|
|
47
47
|
"""Theme parts come from [Content_Types].xml, not from a path pattern.
|
|
48
48
|
|
|
49
|
-
Compressors relocate them —
|
|
50
|
-
ppt/slideMasters/theme/
|
|
49
|
+
Compressors relocate them — some rewrite them under
|
|
50
|
+
ppt/slideMasters/theme/ — and a `ppt/theme/` regex then finds
|
|
51
51
|
nothing to bind the masters to, so every schemeClr resolves to
|
|
52
52
|
UNRESOLVED:no-scheme. The path regex stays as the fallback for packages
|
|
53
53
|
whose content types are unreadable.
|
|
@@ -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))
|
|
@@ -266,8 +266,8 @@ def _recipe_css(fill, line, radii, effects):
|
|
|
266
266
|
|
|
267
267
|
def _sig(fill, line, effects):
|
|
268
268
|
"""分组键 = 填充 + 描边 + 效果。**不含圆角**——OOXML 圆角是 min(w,h) 的百分比,
|
|
269
|
-
同一配方在不同尺寸的卡上绝对 px
|
|
270
|
-
|
|
269
|
+
同一配方在不同尺寸的卡上绝对 px 必然不同,把它计入键会把一个配方拆成多组;
|
|
270
|
+
归一到哪一档是判断,脚本只报区间。"""
|
|
271
271
|
f = 'none'
|
|
272
272
|
if isinstance(fill, dict):
|
|
273
273
|
if fill.get('type') == 'solid':
|
|
@@ -279,18 +279,19 @@ def render_shape(shape, media_url, stats):
|
|
|
279
279
|
|
|
280
280
|
HEAD = """<style>
|
|
281
281
|
body{margin:0;background:#2b2b2b;font-family:%s}
|
|
282
|
-
.page{position:relative;width
|
|
282
|
+
.page{position:relative;width:%dpx;height:%dpx;overflow:hidden}
|
|
283
283
|
.sp{position:absolute;box-sizing:border-box}
|
|
284
284
|
.sp p{margin:0}
|
|
285
285
|
.ph{position:absolute;left:6px;top:4px;font:16px/1.2 monospace;color:#ff2b88;background:#fff9;padding:1px 4px}
|
|
286
286
|
</style>
|
|
287
|
-
"""
|
|
287
|
+
"""
|
|
288
288
|
|
|
289
|
+
# 缩略框跟着画布比例走,缩放比也一起算——写死 16:9 会把非 16:9 模板的页面裁掉一截
|
|
289
290
|
INDEX_EXTRA = """<style>
|
|
290
|
-
.wrap{width
|
|
291
|
+
.wrap{width:%dpx;margin:0 auto;padding:16px 0}
|
|
291
292
|
.lbl{color:#eee;font:13px/1.6 monospace;margin:14px 0 4px}
|
|
292
|
-
.box{width
|
|
293
|
-
.box .page{transform:scale(
|
|
293
|
+
.box{width:%dpx;height:%dpx;overflow:hidden;margin-bottom:6px}
|
|
294
|
+
.box .page{transform:scale(%g);transform-origin:top left}
|
|
294
295
|
</style>
|
|
295
296
|
"""
|
|
296
297
|
|
|
@@ -508,7 +509,8 @@ def render_pages_png(pages, outdir, data, scale=0.5):
|
|
|
508
509
|
for m in data.get('media') or [] if m.get('exported') and m.get('out')}
|
|
509
510
|
png_dir = os.path.join(outdir, 'ref', 'rebuild', 'png')
|
|
510
511
|
os.makedirs(png_dir, exist_ok=True)
|
|
511
|
-
|
|
512
|
+
cpx = ((data.get('canvas') or {}).get('px') or [1920, 1080])
|
|
513
|
+
W, H = int(cpx[0] * scale), int(cpx[1] * scale)
|
|
512
514
|
for kind, n, label, layers, default_color, page_bg in pages:
|
|
513
515
|
canvas = Image.new('RGBA', (W, H), _rgba(page_bg) or (136, 136, 136, 255))
|
|
514
516
|
g = _grad_stops(page_bg or '')
|
|
@@ -646,11 +648,15 @@ def main():
|
|
|
646
648
|
pages = [p for p in pages if p[1] in only]
|
|
647
649
|
|
|
648
650
|
stats = {'shapes': 0, 'text': 0, 'img_ok': 0, 'img_missing': 0, 'no_box': 0}
|
|
649
|
-
|
|
651
|
+
cw, ch = ((data.get('canvas') or {}).get('px') or [1920, 1080])[:2]
|
|
652
|
+
head = HEAD % (CJK_STACK, cw, ch) # 视口跟画布走,非 16:9 模板不能按 1920x1080 裁
|
|
653
|
+
thumb_scale = 960.0 / cw
|
|
654
|
+
index = [head, INDEX_EXTRA % (960, 960, round(ch * thumb_scale), thumb_scale),
|
|
655
|
+
'<div class="wrap">']
|
|
650
656
|
for kind, n, label, layers, default_color, page_bg in (() if args.no_html else pages):
|
|
651
657
|
html = page_html(layers, default_color, page_bg, media_url, stats)
|
|
652
658
|
with open(os.path.join(rebuild, '%s-%d.html' % (kind, n)), 'w', encoding='utf-8') as f:
|
|
653
|
-
f.write(
|
|
659
|
+
f.write(head + html)
|
|
654
660
|
index += ['<div class="lbl">%s</div>' % esc(label),
|
|
655
661
|
'<div class="box">%s</div>' % html]
|
|
656
662
|
index.append('</div>')
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Focused regression tests for deterministic picture compositing."""
|
|
3
|
+
import io
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import unittest
|
|
7
|
+
|
|
8
|
+
from PIL import Image
|
|
9
|
+
|
|
10
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
11
|
+
|
|
12
|
+
from extract import _draw_picture # noqa: E402
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _Archive:
|
|
16
|
+
def __init__(self, media, raw):
|
|
17
|
+
self.names = {media}
|
|
18
|
+
self.zip = self
|
|
19
|
+
self.raw = raw
|
|
20
|
+
|
|
21
|
+
def read(self, media):
|
|
22
|
+
if media not in self.names:
|
|
23
|
+
raise KeyError(media)
|
|
24
|
+
return self.raw
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _png(pixels):
|
|
28
|
+
image = Image.new('RGBA', (len(pixels), 1))
|
|
29
|
+
image.putdata(pixels)
|
|
30
|
+
output = io.BytesIO()
|
|
31
|
+
image.save(output, 'PNG')
|
|
32
|
+
return output.getvalue()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class BackgroundCompositeTest(unittest.TestCase):
|
|
36
|
+
def test_applies_flip_and_opacity_once(self):
|
|
37
|
+
media = 'ppt/media/source.png'
|
|
38
|
+
package = _Archive(media, _png([(255, 0, 0, 255), (0, 0, 255, 255)]))
|
|
39
|
+
canvas = Image.new('RGBA', (2, 1), (255, 255, 255, 255))
|
|
40
|
+
|
|
41
|
+
drawn = _draw_picture(canvas, {
|
|
42
|
+
'media': media,
|
|
43
|
+
'box_unrotated': {'x': 0, 'y': 0, 'w': 2, 'h': 1},
|
|
44
|
+
'flipH': True,
|
|
45
|
+
'opacity': 0.5,
|
|
46
|
+
}, package)
|
|
47
|
+
|
|
48
|
+
self.assertTrue(drawn)
|
|
49
|
+
left, right = canvas.getpixel((0, 0)), canvas.getpixel((1, 0))
|
|
50
|
+
self.assertEqual(left[:3], (128, 128, 255))
|
|
51
|
+
self.assertEqual(right[:3], (255, 128, 128))
|
|
52
|
+
self.assertEqual(left[3], 255)
|
|
53
|
+
self.assertEqual(right[3], 255)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
if __name__ == '__main__':
|
|
57
|
+
unittest.main()
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Regression tests for the consumer-facing layout slot CSS contract."""
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
import tempfile
|
|
6
|
+
import unittest
|
|
7
|
+
|
|
8
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
9
|
+
|
|
10
|
+
from check_v2 import Pack, rule_v2_16 # noqa: E402
|
|
11
|
+
from draft import emit_layouts, slot_style # noqa: E402
|
|
12
|
+
from package import FONTSIZE_RE, build_layouts_md, split_top_blocks # noqa: E402
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class LayoutCssTest(unittest.TestCase):
|
|
16
|
+
def test_text_slot_emits_rendering_style_as_css_only(self):
|
|
17
|
+
shape = {
|
|
18
|
+
'text': {
|
|
19
|
+
'bodyPr': {
|
|
20
|
+
'anchor': 'ctr',
|
|
21
|
+
'insets_px': {'lIns': 12, 'tIns': 8, 'rIns': 10, 'bIns': 6},
|
|
22
|
+
'rot': '900000',
|
|
23
|
+
},
|
|
24
|
+
'lstStyle': {
|
|
25
|
+
'lvl1pPr': {
|
|
26
|
+
'sz_px': 48,
|
|
27
|
+
'weight': 600,
|
|
28
|
+
'italic': True,
|
|
29
|
+
'underline': 'sng',
|
|
30
|
+
'strike': 'sngStrike',
|
|
31
|
+
'spc_px': 1.5,
|
|
32
|
+
'color': {'resolved': '#123456'},
|
|
33
|
+
'algn': 'ctr',
|
|
34
|
+
'lnSpc': {'mult': 1.0},
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
'paragraphs': [],
|
|
38
|
+
},
|
|
39
|
+
}
|
|
40
|
+
slot = {
|
|
41
|
+
'role': 'title',
|
|
42
|
+
'type': 'title',
|
|
43
|
+
'box': [100, 80, 800, 160],
|
|
44
|
+
'sz': 48,
|
|
45
|
+
'txt': '标题',
|
|
46
|
+
}
|
|
47
|
+
slot.update(slot_style(shape))
|
|
48
|
+
archetype = {
|
|
49
|
+
'name': 'layout-1',
|
|
50
|
+
'zh': '标题页',
|
|
51
|
+
'role': 'content',
|
|
52
|
+
'bg': None,
|
|
53
|
+
'slots': [slot],
|
|
54
|
+
'decor': [],
|
|
55
|
+
'pages': [1],
|
|
56
|
+
'rep': 1,
|
|
57
|
+
'pic_n': 0,
|
|
58
|
+
'confidence': 'high',
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
with tempfile.TemporaryDirectory() as output_dir:
|
|
62
|
+
emit_layouts([archetype], output_dir)
|
|
63
|
+
with open(os.path.join(output_dir, 'layouts.yaml'), encoding='utf-8') as stream:
|
|
64
|
+
layouts_yaml = stream.read()
|
|
65
|
+
layouts_md = build_layouts_md(split_top_blocks(layouts_yaml), (1920, 1080))
|
|
66
|
+
|
|
67
|
+
self.assertIn(
|
|
68
|
+
'css: "box-sizing: border-box; padding: 8px 10px 6px 12px; '
|
|
69
|
+
'font-size: 48px; font-weight: 600; font-style: italic; '
|
|
70
|
+
'text-decoration: underline line-through; letter-spacing: 1.5px; color: #123456; '
|
|
71
|
+
'text-align: center; line-height: 1.2; display: flex; '
|
|
72
|
+
'flex-direction: column; justify-content: center; rotate: 15deg"',
|
|
73
|
+
layouts_md,
|
|
74
|
+
)
|
|
75
|
+
for legacy_key in ('size', 'weight', 'color', 'align', 'valign', 'insets_px'):
|
|
76
|
+
self.assertNotRegex(layouts_md, rf'[,{{]\s*{legacy_key}:')
|
|
77
|
+
self.assertEqual(FONTSIZE_RE.findall(layouts_md), ['48'])
|
|
78
|
+
|
|
79
|
+
def test_layout_gate_rejects_legacy_slot_style_keys(self):
|
|
80
|
+
with tempfile.TemporaryDirectory() as pack_dir:
|
|
81
|
+
with open(os.path.join(pack_dir, 'design.md'), 'w', encoding='utf-8') as stream:
|
|
82
|
+
stream.write('---\nversion: alpha\nlayouts: layouts.md\n---\n\nRead layouts.md.\n')
|
|
83
|
+
with open(os.path.join(pack_dir, 'layouts.md'), 'w', encoding='utf-8') as stream:
|
|
84
|
+
stream.write(
|
|
85
|
+
'---\ncanvas: 1920x1080\nlayouts:\n cover:\n role: cover\n'
|
|
86
|
+
' slots:\n - {role: title, type: title, box: [0, 0, 800, 100], '
|
|
87
|
+
'size: 48, align: center}\n confidence: high\n---\n'
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
result = rule_v2_16(Pack(pack_dir))
|
|
91
|
+
|
|
92
|
+
self.assertEqual(result.level, 'FAIL')
|
|
93
|
+
self.assertEqual(len(result.fails), 1)
|
|
94
|
+
self.assertIn('旧样式键 align/size', result.fails[0])
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
if __name__ == '__main__':
|
|
98
|
+
unittest.main()
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
- 消费方直接读 design.md,靠目录约定找 sidecar 与资产;frontmatter `path` 是唯一文件引用点。
|
|
18
18
|
- manifest(`manifest.json`,识别靠内部 `schemaVersion` 字面量不靠文件名)服务存储、索引、校验和迁移;消费模型不需要读它。
|
|
19
|
-
- `ref/` 只放 `audit.yaml`(数值出处的人工复核记录,几 KB)。频次原表、聚类原始数据、`extract.json`、重建图、logo
|
|
19
|
+
- `ref/` 只放 `audit.yaml`(数值出处的人工复核记录,几 KB)。频次原表、聚类原始数据、`extract.json`、重建图、logo 候选图**留在抽取工作目录,不进交付包**——原设计是「下发时链路剥离」,但链路上没有环节真的做剥离,审计材料会连带进消费上下文并占掉包体的大头。
|
|
20
20
|
|
|
21
21
|
## 1. design.md frontmatter 新增键
|
|
22
22
|
|
|
@@ -53,10 +53,10 @@ canvas: 1920x1080 # layouts.md 首键;px = round(EMU / sldSz_cx * 1920)
|
|
|
53
53
|
themes: [dark, light]
|
|
54
54
|
default-theme: dark # 双主题包必填(V2-13)
|
|
55
55
|
colors: # v1 单层扁平,主题进 token 名
|
|
56
|
-
dark-surface: "#
|
|
57
|
-
dark-on-surface: "#
|
|
58
|
-
light-surface: "#
|
|
59
|
-
primary: "#
|
|
56
|
+
dark-surface: "#RRGGBB"
|
|
57
|
+
dark-on-surface: "#RRGGBB"
|
|
58
|
+
light-surface: "#RRGGBB"
|
|
59
|
+
primary: "#RRGGBB" # 共用色不加前缀
|
|
60
60
|
```
|
|
61
61
|
|
|
62
62
|
- token 前缀约定(`dark-X`/`light-X` = 主题专属,无前缀 = 共用)在 `## Usage` 里向消费者写一句;主题机制溯源(clrMap 反转等)落 `ref/audit.yaml`。
|
|
@@ -65,7 +65,7 @@ colors: # v1 单层扁平,主题进 token 名
|
|
|
65
65
|
|
|
66
66
|
### 1.3 排除色
|
|
67
67
|
|
|
68
|
-
- **排除色进 `## Hard Rules`
|
|
68
|
+
- **排除色进 `## Hard Rules` 带证据计数**,正向给替代(如「`<hex>` 为编辑器参考线色(出现 <N> 次),非设计色」)。频次原表与 color-confidence 证据进 `ref/`。
|
|
69
69
|
- 排除色断言必须以**解析后频次**为准,`styleRef` 主题兜底引用(不渲染)与真实设计用色分开。
|
|
70
70
|
|
|
71
71
|
## 2. `assets` 段
|
|
@@ -84,7 +84,7 @@ assets:
|
|
|
84
84
|
kind: background
|
|
85
85
|
role: cover # cover | content | section | closing | accent
|
|
86
86
|
theme: dark
|
|
87
|
-
recipe: "linear-gradient(
|
|
87
|
+
recipe: "linear-gradient(<angle>, <color> 0%, <color> 100%)" # 可选:CSS 重绘配方
|
|
88
88
|
bg-content-solid: # 纯色背景:无 path/url,引 colors token
|
|
89
89
|
kind: background
|
|
90
90
|
role: content
|
|
@@ -123,13 +123,17 @@ layouts:
|
|
|
123
123
|
themes: [dark, light] # 深浅孪生合并
|
|
124
124
|
background: {dark: bg-cover-dark, light: bg-cover-light}
|
|
125
125
|
slots:
|
|
126
|
-
- {role: title, box: [
|
|
127
|
-
- {role: logo, box: [
|
|
126
|
+
- {role: title, box: [<x>, <y>, <w>, <h>], type: title, css: "<CSS 声明串>"}
|
|
127
|
+
- {role: logo, box: [<x>, <y>, <w>, <h>], asset: {dark: logo-on-dark, light: logo-on-light}}
|
|
128
|
+
decor:
|
|
129
|
+
- {box: [<x>, <y>, <w>, <h>], geom: ellipse, css: "<CSS 声明串>"}
|
|
128
130
|
confidence: high
|
|
129
131
|
```
|
|
130
132
|
|
|
131
133
|
- **`background` 三形态**:`<asset-id>` / `{<theme>: <asset-id>}` / `{color: <colors-token>}`(`color` 是保留键,主题名禁止叫 color)。`asset` 两形态:`<asset-id>` / `{<theme>: <asset-id>}`。
|
|
132
134
|
- **背景安全扩展**:有真实背景图的 archetype 建议写 `text_safe: [x,y,w,h]`、`avoid: [{box: [x,y,w,h], reason: "..."}]`、`pairing_rule: "..."`。这些是消费约束,不参与封闭枚举;用于避免标题、正文、图表、卡片、表格、时间线及其容器外接矩形覆盖背景视觉主体、强光斑或深色透明区;透明容器也不能跨进禁放区。
|
|
135
|
+
- **`decor`(可选)**:这一页无文字的图形骨架——图标托底的圆、卡片、分隔线。每条 `{box, geom, css}`:`box` 定位,`css` 是可直接写进 style 的声明串,`geom` 取源形状的 prst(`ellipse` 另加 `border-radius: 50%`)。层级在背景之上、`slots` 之下;带 `asset` 的槽落在 decor 之上是版式本意,不算重叠。
|
|
136
|
+
- **slot 样式契约**:`box` 只承载 `[x,y,w,h]` 几何;可渲染属性统一放进 `css`,并可直接写入 HTML `style`。PPTX `bodyPr.insets_px` 转成 `box-sizing: border-box; padding: ...`,字号/字重/颜色/水平与垂直对齐/行高/字距/旋转分别转成标准 CSS。禁止在 slot 中输出 `size` / `weight` / `color` / `align` / `valign` / `insets_px` 等旧字段。
|
|
133
137
|
- **`type` 封闭枚举**:`title | subtitle | body | pic | table | chart | media | slide-number | footer`。大数字/序号走 `type: title`,语义由 `role`(如 `big-number`)承担。
|
|
134
138
|
- **`slots.*.role` 开放不校验**(语义槽位):优先复用已知词表(OOXML ST_SlideLayoutType / Slidev 20 布局 / Google PredefinedLayout,如 big-number、caption、main-point),确无对应再自造。
|
|
135
139
|
- archetype ≤15(内联降级形态 ≤11);深浅孪生合并为一条;版式溯源/母版取舍进 `ref/`。
|
|
@@ -139,14 +143,14 @@ layouts:
|
|
|
139
143
|
|
|
140
144
|
```yaml
|
|
141
145
|
safe-area: # 开放命名 map,可多套边距体系
|
|
142
|
-
content: {top:
|
|
143
|
-
editorial: {left:
|
|
146
|
+
content: {top: <px>, right: <px>, bottom: <px>, left: <px>, applies-to: [content, quote]}
|
|
147
|
+
editorial: {left: <px>, right: <px>, applies-to: [cover, section, closing]}
|
|
144
148
|
confidence: medium
|
|
145
149
|
```
|
|
146
150
|
|
|
147
151
|
冲突裁决:`slots.box` 是实例真值,`safe-area` 是归纳框架,**以 slots.box 为准**。
|
|
148
152
|
|
|
149
|
-
## 5. check_v2 校验(
|
|
153
|
+
## 5. check_v2 校验(19 行:V2-1..V2-16 + V2-R5/R6/R7)
|
|
150
154
|
|
|
151
155
|
check_v1 全部规则原样生效。扫描范围 = 包目录,V2-1/V2-2 跨 design.md + layouts.md 求并集。
|
|
152
156
|
|
|
@@ -165,6 +169,9 @@ check_v1 全部规则原样生效。扫描范围 = 包目录,V2-1/V2-2 跨 des
|
|
|
165
169
|
| V2-11 | 键名命中 YAML 1.1 布尔字面量 | FAIL |
|
|
166
170
|
| V2-12 | `path`/`url`/`color` 恰好存在一个;`color` 仅 background 允许;`full` 仅可伴随 `path` | FAIL |
|
|
167
171
|
| V2-13 | 多主题(`themes` 长度 >1)缺 `default-theme`(只看 design.md frontmatter——冲突以 design.md 为准) | WARN |
|
|
172
|
+
| V2-14 | 版式的 `flow` 与 `slots` 互斥(同时出现 = FAIL);`flow.regions[].kind` 在 `grid`/`stack`/`free` 内,`grid` 必带 `cols` | FAIL |
|
|
173
|
+
| V2-15 | 同段字段自洽:`backgrounds.*` 的 `text_safe` 不得与任一 `avoid` 相交;两者形态须为 `[x, y, w, h]` 四个数且 w/h 为正 | FAIL |
|
|
174
|
+
| V2-16 | slot/flow item 的渲染样式只通过 `css` 承载;出现 `size/weight/color/align/valign/insets_px` 旧键 | FAIL |
|
|
168
175
|
| V2-R5 | sidecar frontmatter 重复 design.md 已有顶层键(`layouts` 载荷键与 `canvas` 除外——canvas 的家就在 sidecar) | WARN |
|
|
169
176
|
| V2-R6 | assets 条目出现审计字段(boxes/aspect/mark/confidence)或 design.md 顶层出现 canvas/canvas-source/theme-mechanism/color-confidence——应移 `ref/audit.yaml` / layouts.md | WARN |
|
|
170
177
|
| V2-R7 | 有 layouts sidecar 指针但正文未出现 `layouts.md` 字样(弱指针,消费者到不了版式数据) | WARN |
|