@lark-apaas/coding-steering 0.1.32-beta.0 → 0.1.32-dev.5abff3b
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 +6 -6
- package/steering/design-html/skills/charts/SKILL.md +4 -0
- package/steering/design-html/skills/pptx-style-extract/SKILL.md +64 -26
- 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 +2735 -288
- package/steering/design-html/skills/pptx-style-extract/scripts/extract.py +325 -22
- package/steering/design-html/skills/pptx-style-extract/scripts/ooxml.py +19 -2
- package/steering/design-html/skills/pptx-style-extract/scripts/package.py +991 -165
- 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 +16 -10
- package/steering/design-html/skills/pptx-style-extract/scripts/test_asset_judgment_package.py +443 -0
- 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 +528 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/test_layout_css.py +1513 -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 +315 -0
- package/steering/design-html/skills/pptx-style-extract/v2-format-spec.md +27 -15
- package/steering/design-html/skills/preflight/scripts/probe.sh +0 -0
- package/steering/nestjs-react-fullstack/skills/app-init-feasibility-guide/SKILL.md +1 -0
- package/steering/nestjs-react-fullstack/skills/authn-guide/SKILL.md +6 -0
- package/steering/nestjs-react-fullstack/skills/authz-guide/SKILL.md +5 -5
- package/steering/nestjs-react-fullstack/skills/authz-guide/references/dynamic-permission-guide.md +1 -1
- package/steering/nestjs-react-fullstack/skills/client-builtins-file-storage-service/SKILL.md +37 -113
- package/steering/nestjs-react-fullstack/skills/client-builtins-user-service/SKILL.md +13 -2
- package/steering/nestjs-react-fullstack/skills/code-fix/SKILL.md +7 -7
- package/steering/nestjs-react-fullstack/skills/coding-guide/SKILL.md +149 -24
- package/steering/nestjs-react-fullstack/skills/connections-sdk/SKILL.md +202 -0
- package/steering/nestjs-react-fullstack/skills/nestjs-cache/SKILL.md +255 -0
- package/steering/nestjs-react-fullstack/skills/plugin-guide/SKILL.md +158 -543
- package/steering/nestjs-react-fullstack/skills/plugin-guide/references/plugin-coding-guide.md +15 -1
- package/steering/nestjs-react-fullstack/skills/plugin-guide/references/table.md +30 -14
- package/steering/nestjs-react-fullstack/skills/raw-sql-boundary-audit/SKILL.md +63 -0
- package/steering/nestjs-react-fullstack/skills/server-builtins-file-storage-service/SKILL.md +1 -1
- package/steering/nestjs-react-fullstack/skills_common/trigger-guide/SKILL.md +284 -12
- 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
- package/steering/nestjs-react-fullstack/skills/client-add-aily-web-chat/SKILL.md +0 -139
- package/steering/nestjs-react-fullstack/skills/feishu/SKILL.md +0 -269
- package/steering/nestjs-react-fullstack/skills/feishu/references/approval.md +0 -214
- package/steering/nestjs-react-fullstack/skills/feishu/references/attendance.md +0 -163
- package/steering/nestjs-react-fullstack/skills/feishu/references/bitable.md +0 -311
- package/steering/nestjs-react-fullstack/skills/feishu/references/calendar.md +0 -190
- package/steering/nestjs-react-fullstack/skills/feishu/references/contacts.md +0 -160
- package/steering/nestjs-react-fullstack/skills/feishu/references/doc.md +0 -257
- package/steering/nestjs-react-fullstack/skills/feishu/references/drive.md +0 -104
- package/steering/nestjs-react-fullstack/skills/feishu/references/events.md +0 -199
- package/steering/nestjs-react-fullstack/skills/feishu/references/id-convert.md +0 -128
- package/steering/nestjs-react-fullstack/skills/feishu/references/messaging.md +0 -207
- package/steering/nestjs-react-fullstack/skills/feishu/references/oauth.md +0 -165
- package/steering/nestjs-react-fullstack/skills/feishu/references/perm.md +0 -91
- package/steering/nestjs-react-fullstack/skills/feishu/references/wiki.md +0 -165
- package/steering/nestjs-react-fullstack/skills_common/trigger-guide/references/trigger-lifecycle.md +0 -301
|
@@ -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 '')
|
|
@@ -642,15 +644,19 @@ def main():
|
|
|
642
644
|
tx1_of_master.get(master, '#000000'),
|
|
643
645
|
layout_bg.get(part, '#888')))
|
|
644
646
|
|
|
645
|
-
if only is not None:
|
|
646
|
-
pages = [p for p in pages if p[
|
|
647
|
+
if only is not None: # 对样张和版式一律生效(form=3 的代表页是版式,不是样张)
|
|
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,443 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Integration regression tests for model-decided image roles."""
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import tempfile
|
|
7
|
+
import unittest
|
|
8
|
+
|
|
9
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
10
|
+
|
|
11
|
+
from check_v2 import Pack, RULES # noqa: E402
|
|
12
|
+
from package import (Fail, apply_asset_decisions, bind_asset_vision_group_scopes,
|
|
13
|
+
load_layout_blocks, main as package_main, # noqa: E402
|
|
14
|
+
validate_asset_vision_groups)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AssetJudgmentPackageTest(unittest.TestCase):
|
|
18
|
+
def test_asset_vision_index_requires_selected_candidates(self):
|
|
19
|
+
with tempfile.TemporaryDirectory() as lout:
|
|
20
|
+
with open(os.path.join(lout, 'asset-vision-groups.json'),
|
|
21
|
+
'w', encoding='utf-8') as stream:
|
|
22
|
+
json.dump({'version': 2}, stream)
|
|
23
|
+
|
|
24
|
+
with self.assertRaisesRegex(Fail, '缺少 selected'):
|
|
25
|
+
validate_asset_vision_groups(lout, {'asset_vision_groups': []})
|
|
26
|
+
|
|
27
|
+
def test_asset_vision_groups_must_keep_every_extracted_candidate(self):
|
|
28
|
+
with tempfile.TemporaryDirectory() as lout:
|
|
29
|
+
with open(os.path.join(lout, 'asset-vision-groups.json'),
|
|
30
|
+
'w', encoding='utf-8') as stream:
|
|
31
|
+
json.dump({
|
|
32
|
+
'version': 2,
|
|
33
|
+
'selected': [{
|
|
34
|
+
'candidates': [{
|
|
35
|
+
'id': 'asset-cover',
|
|
36
|
+
'source_media': 'cover.png',
|
|
37
|
+
'placements': [{
|
|
38
|
+
'box': [100, 200, 300, 400],
|
|
39
|
+
'archetype': 'cover',
|
|
40
|
+
}],
|
|
41
|
+
}, {
|
|
42
|
+
'id': 'asset-ornament',
|
|
43
|
+
'source_media': 'ornament.png',
|
|
44
|
+
'placements': [{
|
|
45
|
+
'box': [10, 20, 30, 40],
|
|
46
|
+
'archetype': 'content',
|
|
47
|
+
}],
|
|
48
|
+
}],
|
|
49
|
+
}],
|
|
50
|
+
}, stream)
|
|
51
|
+
manifest = {
|
|
52
|
+
'asset_vision_groups': [{
|
|
53
|
+
'id': 'asset-cover',
|
|
54
|
+
'source_media': 'cover.png',
|
|
55
|
+
'layout': 'cover',
|
|
56
|
+
'box': '[100, 200, 300, 400]',
|
|
57
|
+
'visual_kind': 'illustration',
|
|
58
|
+
}, {
|
|
59
|
+
'id': 'asset-ornament',
|
|
60
|
+
'source_media': 'ornament.png',
|
|
61
|
+
'layout': 'content',
|
|
62
|
+
'box': '[10, 20, 30, 40]',
|
|
63
|
+
'visual_kind': 'decorative',
|
|
64
|
+
}],
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
validate_asset_vision_groups(lout, manifest)
|
|
68
|
+
manifest['asset_vision_groups'].pop()
|
|
69
|
+
|
|
70
|
+
with self.assertRaisesRegex(Fail, '逐项保留抽取候选.*asset-ornament'):
|
|
71
|
+
validate_asset_vision_groups(lout, manifest)
|
|
72
|
+
|
|
73
|
+
def test_asset_vision_groups_reject_rewritten_extracted_candidate_scope(self):
|
|
74
|
+
with tempfile.TemporaryDirectory() as lout:
|
|
75
|
+
with open(os.path.join(lout, 'asset-vision-groups.json'),
|
|
76
|
+
'w', encoding='utf-8') as stream:
|
|
77
|
+
json.dump({
|
|
78
|
+
'version': 2,
|
|
79
|
+
'selected': [{
|
|
80
|
+
'candidates': [{
|
|
81
|
+
'id': 'asset-ornament',
|
|
82
|
+
'source_media': 'ornament.png',
|
|
83
|
+
'placements': [{
|
|
84
|
+
'box': [10, 20, 30, 40],
|
|
85
|
+
'archetype': None,
|
|
86
|
+
}],
|
|
87
|
+
}],
|
|
88
|
+
}],
|
|
89
|
+
}, stream)
|
|
90
|
+
manifest = {
|
|
91
|
+
'asset_vision_groups': [{
|
|
92
|
+
'id': 'asset-ornament',
|
|
93
|
+
'source_media': 'other.png',
|
|
94
|
+
'box': '[10, 20, 30, 40]',
|
|
95
|
+
'visual_kind': 'decorative',
|
|
96
|
+
}],
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
with self.assertRaisesRegex(Fail, '改写了抽取候选的图片或位置'):
|
|
100
|
+
validate_asset_vision_groups(lout, manifest)
|
|
101
|
+
|
|
102
|
+
def test_v2_candidate_ids_resolve_same_box_on_different_layouts(self):
|
|
103
|
+
with tempfile.TemporaryDirectory() as lout:
|
|
104
|
+
with open(os.path.join(lout, 'asset-vision-groups.json'),
|
|
105
|
+
'w', encoding='utf-8') as stream:
|
|
106
|
+
json.dump({
|
|
107
|
+
'version': 2,
|
|
108
|
+
'selected': [{
|
|
109
|
+
'candidates': [{
|
|
110
|
+
'id': 'asset-cover-logo',
|
|
111
|
+
'source_media': 'shared.png',
|
|
112
|
+
'placements': [{
|
|
113
|
+
'box': [100, 200, 80, 80],
|
|
114
|
+
'archetype': 'cover',
|
|
115
|
+
}],
|
|
116
|
+
}, {
|
|
117
|
+
'id': 'asset-content-ornament',
|
|
118
|
+
'source_media': 'shared.png',
|
|
119
|
+
'placements': [{
|
|
120
|
+
'box': [100, 200, 80, 80],
|
|
121
|
+
'archetype': 'content',
|
|
122
|
+
}],
|
|
123
|
+
}],
|
|
124
|
+
}],
|
|
125
|
+
}, stream)
|
|
126
|
+
manifest = {
|
|
127
|
+
'assets': [],
|
|
128
|
+
'asset_vision_groups': [{
|
|
129
|
+
'id': 'asset-cover-logo',
|
|
130
|
+
'source_media': 'shared.png',
|
|
131
|
+
'box': '[100, 200, 80, 80]',
|
|
132
|
+
'visual_kind': 'logo',
|
|
133
|
+
}, {
|
|
134
|
+
'id': 'asset-content-ornament',
|
|
135
|
+
'source_media': 'shared.png',
|
|
136
|
+
'box': '[100, 200, 80, 80]',
|
|
137
|
+
'visual_kind': 'decorative',
|
|
138
|
+
}],
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
bind_asset_vision_group_scopes(lout, manifest)
|
|
142
|
+
decisions, _ = apply_asset_decisions(
|
|
143
|
+
manifest,
|
|
144
|
+
bound_sources={'shared.png'},
|
|
145
|
+
bound_slots={
|
|
146
|
+
'shared.png': {
|
|
147
|
+
('cover', (100, 200, 80, 80)),
|
|
148
|
+
('content', (100, 200, 80, 80)),
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
self.assertEqual(
|
|
154
|
+
decisions.for_scope('shared.png', 'cover', (100, 200, 80, 80)),
|
|
155
|
+
'logo',
|
|
156
|
+
)
|
|
157
|
+
self.assertEqual(
|
|
158
|
+
decisions.for_scope('shared.png', 'content', (100, 200, 80, 80)),
|
|
159
|
+
'texture',
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
def test_legacy_asset_vision_index_accepts_a_group_default(self):
|
|
163
|
+
with tempfile.TemporaryDirectory() as lout:
|
|
164
|
+
with open(os.path.join(lout, 'asset-vision-groups.json'),
|
|
165
|
+
'w', encoding='utf-8') as stream:
|
|
166
|
+
json.dump({
|
|
167
|
+
'version': 1,
|
|
168
|
+
'selected': [{
|
|
169
|
+
'candidates': [{
|
|
170
|
+
'id': 'asset-cover',
|
|
171
|
+
'source_media': 'cover.png',
|
|
172
|
+
'placements': [{'box': [0, 0, 100, 100]}],
|
|
173
|
+
}, {
|
|
174
|
+
'id': 'asset-ornament',
|
|
175
|
+
'source_media': 'ornament.png',
|
|
176
|
+
'placements': [{'box': [10, 20, 30, 40]}],
|
|
177
|
+
}],
|
|
178
|
+
}],
|
|
179
|
+
}, stream)
|
|
180
|
+
manifest = {
|
|
181
|
+
'asset_vision_groups': [{
|
|
182
|
+
'id': 'vision-1',
|
|
183
|
+
'source_media': '[cover.png, ornament.png]',
|
|
184
|
+
'visual_kind': 'decorative',
|
|
185
|
+
}],
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
validate_asset_vision_groups(lout, manifest)
|
|
189
|
+
|
|
190
|
+
def test_layout_controls_override_legacy_judgment_without_reading_layout_todos(self):
|
|
191
|
+
with tempfile.TemporaryDirectory() as root:
|
|
192
|
+
lout = os.path.join(root, 'l-out')
|
|
193
|
+
os.makedirs(lout)
|
|
194
|
+
with open(os.path.join(lout, 'layouts.yaml'), 'w', encoding='utf-8') as stream:
|
|
195
|
+
stream.write(
|
|
196
|
+
'names:\n'
|
|
197
|
+
' content: TODO中文名\n'
|
|
198
|
+
'roles:\n'
|
|
199
|
+
' content: TODO角色\n'
|
|
200
|
+
'layouts:\n'
|
|
201
|
+
' content:\n'
|
|
202
|
+
' slots:\n'
|
|
203
|
+
' - {role: body, box: [10, 20, 30, 40], type: body}\n'
|
|
204
|
+
' confidence: high\n'
|
|
205
|
+
)
|
|
206
|
+
with open(os.path.join(lout, 'layout-controls.yaml'), 'w', encoding='utf-8') as stream:
|
|
207
|
+
stream.write(
|
|
208
|
+
'names:\n'
|
|
209
|
+
' content: "内容页"\n'
|
|
210
|
+
'roles:\n'
|
|
211
|
+
' content: content\n'
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
blocks = load_layout_blocks(lout)
|
|
215
|
+
|
|
216
|
+
self.assertEqual([key for key, _, _ in blocks],
|
|
217
|
+
['names', 'roles', 'layouts'])
|
|
218
|
+
self.assertIn('内容页', blocks[0][2][0])
|
|
219
|
+
self.assertIn('content', blocks[1][2][0])
|
|
220
|
+
self.assertNotIn('TODO', '\n'.join(
|
|
221
|
+
line for _, _, lines in blocks for line in lines
|
|
222
|
+
))
|
|
223
|
+
|
|
224
|
+
def test_layout_controls_reject_non_judgment_block(self):
|
|
225
|
+
with tempfile.TemporaryDirectory() as root:
|
|
226
|
+
lout = os.path.join(root, 'l-out')
|
|
227
|
+
os.makedirs(lout)
|
|
228
|
+
with open(os.path.join(lout, 'layouts.yaml'), 'w', encoding='utf-8') as stream:
|
|
229
|
+
stream.write('layouts:\n content:\n role: content\n')
|
|
230
|
+
with open(os.path.join(lout, 'layout-controls.yaml'), 'w', encoding='utf-8') as stream:
|
|
231
|
+
stream.write('layouts:\n cover:\n role: cover\n')
|
|
232
|
+
|
|
233
|
+
with self.assertRaisesRegex(Fail, '只能包含'):
|
|
234
|
+
load_layout_blocks(lout)
|
|
235
|
+
|
|
236
|
+
def test_layout_controls_must_cover_all_legacy_judgment_blocks(self):
|
|
237
|
+
with tempfile.TemporaryDirectory() as root:
|
|
238
|
+
lout = os.path.join(root, 'l-out')
|
|
239
|
+
os.makedirs(lout)
|
|
240
|
+
with open(os.path.join(lout, 'layouts.yaml'), 'w', encoding='utf-8') as stream:
|
|
241
|
+
stream.write(
|
|
242
|
+
'names:\n'
|
|
243
|
+
' content: "内容页"\n'
|
|
244
|
+
'roles:\n'
|
|
245
|
+
' content: content\n'
|
|
246
|
+
'layouts:\n'
|
|
247
|
+
' content:\n'
|
|
248
|
+
' role: content\n'
|
|
249
|
+
)
|
|
250
|
+
with open(os.path.join(lout, 'layout-controls.yaml'), 'w',
|
|
251
|
+
encoding='utf-8') as stream:
|
|
252
|
+
stream.write('roles:\n content: content\n')
|
|
253
|
+
|
|
254
|
+
with self.assertRaisesRegex(Fail, '缺:names'):
|
|
255
|
+
load_layout_blocks(lout)
|
|
256
|
+
|
|
257
|
+
def test_layout_controls_must_keep_each_legacy_judgment_entry(self):
|
|
258
|
+
with tempfile.TemporaryDirectory() as root:
|
|
259
|
+
lout = os.path.join(root, 'l-out')
|
|
260
|
+
os.makedirs(lout)
|
|
261
|
+
with open(os.path.join(lout, 'layouts.yaml'), 'w', encoding='utf-8') as stream:
|
|
262
|
+
stream.write(
|
|
263
|
+
'names:\n'
|
|
264
|
+
' cover: "封面"\n'
|
|
265
|
+
' content: "内容页"\n'
|
|
266
|
+
'roles:\n'
|
|
267
|
+
' cover: cover\n'
|
|
268
|
+
' content: content\n'
|
|
269
|
+
'layouts:\n'
|
|
270
|
+
' cover:\n'
|
|
271
|
+
' role: cover\n'
|
|
272
|
+
' content:\n'
|
|
273
|
+
' role: content\n'
|
|
274
|
+
)
|
|
275
|
+
with open(os.path.join(lout, 'layout-controls.yaml'), 'w',
|
|
276
|
+
encoding='utf-8') as stream:
|
|
277
|
+
stream.write(
|
|
278
|
+
'names:\n'
|
|
279
|
+
' cover: "封面"\n'
|
|
280
|
+
'roles:\n'
|
|
281
|
+
' cover: cover\n'
|
|
282
|
+
' content: content\n'
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
with self.assertRaisesRegex(Fail, 'names 段缺少旧判断条目:content'):
|
|
286
|
+
load_layout_blocks(lout)
|
|
287
|
+
|
|
288
|
+
def test_package_strips_judgment_fields_and_preserves_content_slot(self):
|
|
289
|
+
try:
|
|
290
|
+
from PIL import Image
|
|
291
|
+
except ImportError:
|
|
292
|
+
self.skipTest('Pillow unavailable')
|
|
293
|
+
|
|
294
|
+
with tempfile.TemporaryDirectory() as root:
|
|
295
|
+
stage1 = os.path.join(root, 'stage1')
|
|
296
|
+
lout = os.path.join(stage1, 'l-out')
|
|
297
|
+
pack = os.path.join(root, 'pack')
|
|
298
|
+
media = os.path.join(stage1, 'media-out')
|
|
299
|
+
ref = os.path.join(stage1, 'ref')
|
|
300
|
+
os.makedirs(lout)
|
|
301
|
+
os.makedirs(media)
|
|
302
|
+
os.makedirs(ref)
|
|
303
|
+
|
|
304
|
+
Image.new('RGBA', (40, 20), (20, 40, 60, 255)).save(
|
|
305
|
+
os.path.join(media, 'partner-logo.png'))
|
|
306
|
+
Image.new('RGBA', (24, 24), (60, 40, 20, 255)).save(
|
|
307
|
+
os.path.join(media, 'ornament.png'))
|
|
308
|
+
|
|
309
|
+
box_rows = [
|
|
310
|
+
('ppt/media/image1.png', 'partner-logo.png', [100, 200, 400, 200]),
|
|
311
|
+
('ppt/media/image2.png', 'ornament.png', [40, 40, 120, 120]),
|
|
312
|
+
]
|
|
313
|
+
extract = {
|
|
314
|
+
'source': {'filename': 'fixture.pptx'},
|
|
315
|
+
'canvas': {
|
|
316
|
+
'px': [1920, 1080],
|
|
317
|
+
'source': {'cx': 12192000, 'cy': 6858000, 'unit': 'EMU'},
|
|
318
|
+
},
|
|
319
|
+
'media': [
|
|
320
|
+
{
|
|
321
|
+
'media': source,
|
|
322
|
+
'out': 'media-out/' + output,
|
|
323
|
+
'source_px': [40, 20] if output == 'partner-logo.png' else [24, 24],
|
|
324
|
+
}
|
|
325
|
+
for source, output, _ in box_rows
|
|
326
|
+
],
|
|
327
|
+
'images': [
|
|
328
|
+
{
|
|
329
|
+
'media': source,
|
|
330
|
+
'boxes': [{
|
|
331
|
+
'count': 1,
|
|
332
|
+
'box': {'x': box[0], 'y': box[1], 'w': box[2], 'h': box[3]},
|
|
333
|
+
'parts': ['ppt/slides/slide1.xml'],
|
|
334
|
+
}],
|
|
335
|
+
}
|
|
336
|
+
for source, _, box in box_rows
|
|
337
|
+
],
|
|
338
|
+
'color_freq': [],
|
|
339
|
+
'text_scale': [],
|
|
340
|
+
}
|
|
341
|
+
with open(os.path.join(stage1, 'extract.json'), 'w', encoding='utf-8') as stream:
|
|
342
|
+
json.dump(extract, stream)
|
|
343
|
+
with open(os.path.join(ref, 'shapes.json'), 'w', encoding='utf-8') as stream:
|
|
344
|
+
json.dump({
|
|
345
|
+
'shapes': [
|
|
346
|
+
{
|
|
347
|
+
'part': 'ppt/slides/slide1.xml',
|
|
348
|
+
'kind': 'pic',
|
|
349
|
+
'name': output,
|
|
350
|
+
'box': {'x': box[0], 'y': box[1], 'w': box[2], 'h': box[3]},
|
|
351
|
+
}
|
|
352
|
+
for _, output, box in box_rows
|
|
353
|
+
],
|
|
354
|
+
}, stream)
|
|
355
|
+
|
|
356
|
+
with open(os.path.join(lout, 'manifest.yaml'), 'w', encoding='utf-8') as stream:
|
|
357
|
+
stream.write(
|
|
358
|
+
'version: alpha\n'
|
|
359
|
+
'name: asset-judgment-fixture\n'
|
|
360
|
+
'description: >\n'
|
|
361
|
+
' A restrained fixture with explicit image-role judgments and traced layout slots.\n'
|
|
362
|
+
'assets:\n'
|
|
363
|
+
' - id: logo-primary\n'
|
|
364
|
+
' source_media: partner-logo.png\n'
|
|
365
|
+
' kind: logo\n'
|
|
366
|
+
' on-bg: light\n'
|
|
367
|
+
'asset_vision_groups:\n'
|
|
368
|
+
' - id: vision-1\n'
|
|
369
|
+
' source_media: [partner-logo.png, ornament.png]\n'
|
|
370
|
+
' visual_kind: content-image\n'
|
|
371
|
+
'asset_decisions:\n'
|
|
372
|
+
' - source_media: ornament.png\n'
|
|
373
|
+
' visual_kind: decorative\n'
|
|
374
|
+
)
|
|
375
|
+
with open(os.path.join(lout, 'frontmatter.yaml'), 'w', encoding='utf-8') as stream:
|
|
376
|
+
stream.write('')
|
|
377
|
+
with open(os.path.join(lout, 'layouts.yaml'), 'w', encoding='utf-8') as stream:
|
|
378
|
+
stream.write(
|
|
379
|
+
'roles:\n'
|
|
380
|
+
' content: TODO角色\n'
|
|
381
|
+
'layouts:\n'
|
|
382
|
+
' content:\n'
|
|
383
|
+
' name: "内容页"\n'
|
|
384
|
+
' role: content\n'
|
|
385
|
+
' slots:\n'
|
|
386
|
+
' - {role: logo, box: [100, 200, 400, 200], type: pic, '
|
|
387
|
+
'asset: logo-primary, source_media: partner-logo.png}\n'
|
|
388
|
+
' - {role: asset-candidate, box: [40, 40, 120, 120], type: pic, '
|
|
389
|
+
'source_media: ornament.png}\n'
|
|
390
|
+
' confidence: high\n'
|
|
391
|
+
)
|
|
392
|
+
with open(os.path.join(lout, 'layout-controls.yaml'), 'w',
|
|
393
|
+
encoding='utf-8') as stream:
|
|
394
|
+
stream.write('roles:\n content: content\n')
|
|
395
|
+
with open(os.path.join(lout, 'body.md'), 'w', encoding='utf-8') as stream:
|
|
396
|
+
stream.write(
|
|
397
|
+
'## Usage\n\n'
|
|
398
|
+
'Read `layouts.md` before composing a slide.\n\n'
|
|
399
|
+
'## Assets\n\n'
|
|
400
|
+
'{{ASSET_TABLE}}\n\n'
|
|
401
|
+
'## Hard Rules\n\n'
|
|
402
|
+
'{{LOGO_RULES}}\n'
|
|
403
|
+
)
|
|
404
|
+
check_v1 = os.path.join(root, 'check_v1.py')
|
|
405
|
+
with open(check_v1, 'w', encoding='utf-8') as stream:
|
|
406
|
+
stream.write('raise SystemExit(0)\n')
|
|
407
|
+
|
|
408
|
+
rc = package_main([stage1, lout, pack, '--check-v1', check_v1])
|
|
409
|
+
|
|
410
|
+
self.assertEqual(rc, 0)
|
|
411
|
+
with open(os.path.join(pack, 'design.md'), encoding='utf-8') as stream:
|
|
412
|
+
design = stream.read()
|
|
413
|
+
with open(os.path.join(pack, 'layouts.md'), encoding='utf-8') as stream:
|
|
414
|
+
layouts = stream.read()
|
|
415
|
+
self.assertNotIn('asset_decisions', design)
|
|
416
|
+
self.assertNotIn('asset_vision_groups', design)
|
|
417
|
+
self.assertNotIn('visual_kind', design)
|
|
418
|
+
self.assertNotIn('source_media', design)
|
|
419
|
+
self.assertNotIn('source_media', layouts)
|
|
420
|
+
self.assertNotIn('TODO', layouts)
|
|
421
|
+
self.assertNotIn('{{LOGO_RULES}}', design)
|
|
422
|
+
self.assertNotIn('logo-primary', design)
|
|
423
|
+
self.assertNotIn('logo-primary', layouts)
|
|
424
|
+
self.assertIn(
|
|
425
|
+
'- {role: pic, box: [100, 200, 400, 200], type: pic}',
|
|
426
|
+
layouts,
|
|
427
|
+
)
|
|
428
|
+
self.assertIn('texture-1:', design)
|
|
429
|
+
self.assertIn('kind: texture', design)
|
|
430
|
+
self.assertIn(
|
|
431
|
+
'- {role: texture, box: [40, 40, 120, 120], type: pic, asset: texture-1}',
|
|
432
|
+
layouts,
|
|
433
|
+
)
|
|
434
|
+
gate = Pack(pack)
|
|
435
|
+
self.assertFalse([
|
|
436
|
+
result
|
|
437
|
+
for result in (rule(gate) for rule in RULES)
|
|
438
|
+
if result.fails
|
|
439
|
+
])
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
if __name__ == '__main__':
|
|
443
|
+
unittest.main()
|
|
@@ -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()
|