@lark-apaas/coding-steering 0.1.18-dev.857e860 → 0.1.18-dev.87ec805
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 +991 -185
- 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 +4 -9
- 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_color_contract.py +58 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/test_design_consumer_contract.py +59 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/test_layout_css.py +98 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/test_rounded_contract.py +112 -0
- package/steering/design-html/skills/pptx-style-extract/v2-format-spec.md +24 -15
- 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
|
@@ -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))
|
|
@@ -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)'
|
|
@@ -266,8 +262,8 @@ def _recipe_css(fill, line, radii, effects):
|
|
|
266
262
|
|
|
267
263
|
def _sig(fill, line, effects):
|
|
268
264
|
"""分组键 = 填充 + 描边 + 效果。**不含圆角**——OOXML 圆角是 min(w,h) 的百分比,
|
|
269
|
-
同一配方在不同尺寸的卡上绝对 px
|
|
270
|
-
|
|
265
|
+
同一配方在不同尺寸的卡上绝对 px 必然不同,把它计入键会把一个配方拆成多组;
|
|
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'):
|
|
@@ -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,58 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Regression test for the generated consumer-facing color contract."""
|
|
3
|
+
import os
|
|
4
|
+
import tempfile
|
|
5
|
+
import unittest
|
|
6
|
+
|
|
7
|
+
from draft import emit_body
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ColorContractTest(unittest.TestCase):
|
|
11
|
+
def test_body_allows_supporting_colors_without_a_second_accent_system(self):
|
|
12
|
+
data = {
|
|
13
|
+
'canvas': {'px': [1920, 1080]},
|
|
14
|
+
'counts': {'slides': 1},
|
|
15
|
+
'form_hint': {'form': 3},
|
|
16
|
+
}
|
|
17
|
+
tokens = [
|
|
18
|
+
('primary', {'hex': '#123456'}),
|
|
19
|
+
('surface', {'hex': '#FFFFFF'}),
|
|
20
|
+
]
|
|
21
|
+
fonts = [
|
|
22
|
+
{
|
|
23
|
+
'names': ['Example Sans'],
|
|
24
|
+
'stack': ['Example Sans', 'Noto Sans SC'],
|
|
25
|
+
},
|
|
26
|
+
]
|
|
27
|
+
roles = {'body': {'sz_px': 36}}
|
|
28
|
+
archetypes = [
|
|
29
|
+
{
|
|
30
|
+
'name': 'content',
|
|
31
|
+
'slots': [],
|
|
32
|
+
},
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
with tempfile.TemporaryDirectory() as output_dir:
|
|
36
|
+
emit_body(
|
|
37
|
+
data,
|
|
38
|
+
tokens,
|
|
39
|
+
fonts,
|
|
40
|
+
roles,
|
|
41
|
+
[],
|
|
42
|
+
archetypes,
|
|
43
|
+
[],
|
|
44
|
+
{},
|
|
45
|
+
output_dir,
|
|
46
|
+
)
|
|
47
|
+
with open(os.path.join(output_dir, 'body.md'), encoding='utf-8') as stream:
|
|
48
|
+
body = stream.read()
|
|
49
|
+
|
|
50
|
+
self.assertIn('允许新增中性色、低彩度辅助色或局部语义色', body)
|
|
51
|
+
self.assertRegex(body, r'正负.*风险.*警告.*状态.*图表序列')
|
|
52
|
+
self.assertIn('不得自行新增第二强调色', body)
|
|
53
|
+
self.assertIn('高饱和、高对比、大面积或跨页重复', body)
|
|
54
|
+
self.assertIn('标题、关键数字、图表主序列、卡片底色或渐变', body)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
if __name__ == '__main__':
|
|
58
|
+
unittest.main()
|
package/steering/design-html/skills/pptx-style-extract/scripts/test_design_consumer_contract.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Regression tests for consumer rules emitted into design.md."""
|
|
3
|
+
import os
|
|
4
|
+
import tempfile
|
|
5
|
+
import unittest
|
|
6
|
+
|
|
7
|
+
from draft import emit_body
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DesignConsumerContractTest(unittest.TestCase):
|
|
11
|
+
def test_generated_design_owns_template_consumption_rules(self):
|
|
12
|
+
data = {
|
|
13
|
+
'canvas': {'px': [1920, 1080]},
|
|
14
|
+
'counts': {'slides': 1},
|
|
15
|
+
'form_hint': {'form': 3},
|
|
16
|
+
}
|
|
17
|
+
tokens = [('primary', {'hex': '#123456'})]
|
|
18
|
+
fonts = [
|
|
19
|
+
{
|
|
20
|
+
'names': ['Example Sans'],
|
|
21
|
+
'stack': ['Example Sans', 'Noto Sans SC'],
|
|
22
|
+
},
|
|
23
|
+
]
|
|
24
|
+
roles = {'body': {'sz_px': 36}}
|
|
25
|
+
assets = [
|
|
26
|
+
{
|
|
27
|
+
'id': 'bg-content',
|
|
28
|
+
'kind': 'background',
|
|
29
|
+
'role': 'content',
|
|
30
|
+
},
|
|
31
|
+
]
|
|
32
|
+
archetypes = [{'name': 'content', 'slots': []}]
|
|
33
|
+
|
|
34
|
+
with tempfile.TemporaryDirectory() as output_dir:
|
|
35
|
+
emit_body(
|
|
36
|
+
data,
|
|
37
|
+
tokens,
|
|
38
|
+
fonts,
|
|
39
|
+
roles,
|
|
40
|
+
assets,
|
|
41
|
+
archetypes,
|
|
42
|
+
[],
|
|
43
|
+
{},
|
|
44
|
+
output_dir,
|
|
45
|
+
)
|
|
46
|
+
with open(os.path.join(output_dir, 'body.md'), encoding='utf-8') as stream:
|
|
47
|
+
body = stream.read()
|
|
48
|
+
|
|
49
|
+
self.assertIn('是全局 token', body)
|
|
50
|
+
self.assertIn('局部 slot / decor 的 `css` 优先', body)
|
|
51
|
+
self.assertIn('字体使用 Typography 的完整栈与降级', body)
|
|
52
|
+
self.assertIn('将包内 `assets/` 复制到项目内相对目录', body)
|
|
53
|
+
self.assertIn('本机绝对路径', body)
|
|
54
|
+
self.assertIn('背景、资产和本段规则均来自本风格包', body)
|
|
55
|
+
self.assertIn('页面无资源加载失败、内容溢出或画幅裁切', body)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
if __name__ == '__main__':
|
|
59
|
+
unittest.main()
|