@lark-apaas/coding-steering 0.1.32-dev.4f80f68 → 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 +1 -1
- package/steering/design-html/skills/pptx-style-extract/SKILL.md +18 -16
- package/steering/design-html/skills/pptx-style-extract/scripts/draft.py +1094 -174
- package/steering/design-html/skills/pptx-style-extract/scripts/ooxml.py +18 -1
- package/steering/design-html/skills/pptx-style-extract/scripts/package.py +643 -40
- package/steering/design-html/skills/pptx-style-extract/scripts/test_asset_judgment_package.py +443 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/test_design_consumer_contract.py +1 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/test_flow_layout_contract.py +157 -7
- package/steering/design-html/skills/pptx-style-extract/scripts/test_layout_css.py +1417 -2
- package/steering/design-html/skills/pptx-style-extract/scripts/test_text_role_contract.py +151 -4
- package/steering/design-html/skills/pptx-style-extract/v2-format-spec.md +1 -1
|
@@ -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()
|
package/steering/design-html/skills/pptx-style-extract/scripts/test_design_consumer_contract.py
CHANGED
|
@@ -56,6 +56,7 @@ class DesignConsumerContractTest(unittest.TestCase):
|
|
|
56
56
|
self.assertIn('沿用该页型已有的标题层级与局部 `css`', body)
|
|
57
57
|
self.assertIn('背景中已经可见的固定标题不再创建文本', body)
|
|
58
58
|
self.assertIn('没有 `subtitle` 槽就不新增副标题', body)
|
|
59
|
+
self.assertIn('区带自带 `margin: [左, 右]` 时用它的', body)
|
|
59
60
|
|
|
60
61
|
|
|
61
62
|
if __name__ == '__main__':
|
|
@@ -9,7 +9,8 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
|
9
9
|
|
|
10
10
|
from draft import draft_flow, draft_layouts, emit_layouts
|
|
11
11
|
from check_v2 import Pack, rule_v2_14, rule_v2_16
|
|
12
|
-
from package import Fail, build_layouts_md,
|
|
12
|
+
from package import (Fail, apply_asset_decisions, build_layouts_md,
|
|
13
|
+
split_top_blocks)
|
|
13
14
|
|
|
14
15
|
|
|
15
16
|
def text_slot(role, box, text):
|
|
@@ -101,7 +102,7 @@ layouts:
|
|
|
101
102
|
self.assertNotIn(' flow:', slots_md)
|
|
102
103
|
self.assertNotIn('layout_modes:', slots_md)
|
|
103
104
|
|
|
104
|
-
def
|
|
105
|
+
def test_layout_mode_defaults_to_slots_when_both_forms_exist(self):
|
|
105
106
|
draft = """names:
|
|
106
107
|
layout-1: 内容页
|
|
107
108
|
roles:
|
|
@@ -123,8 +124,56 @@ layouts:
|
|
|
123
124
|
confidence: high
|
|
124
125
|
"""
|
|
125
126
|
|
|
126
|
-
|
|
127
|
-
|
|
127
|
+
layouts_md = build_layouts_md(split_top_blocks(draft), (1920, 1080))
|
|
128
|
+
|
|
129
|
+
self.assertIn(' slots:', layouts_md)
|
|
130
|
+
self.assertNotIn(' flow:', layouts_md)
|
|
131
|
+
|
|
132
|
+
def test_flow_instance_override_uses_source_box_without_leaking_it(self):
|
|
133
|
+
manifest = {
|
|
134
|
+
'assets': [],
|
|
135
|
+
'asset_vision_groups': [{
|
|
136
|
+
'id': 'vision-1',
|
|
137
|
+
'source_media': '[shared.png]',
|
|
138
|
+
'visual_kind': 'content-image',
|
|
139
|
+
}],
|
|
140
|
+
'asset_decisions': [{
|
|
141
|
+
'source_media': 'shared.png',
|
|
142
|
+
'box': '[100, 200, 80, 80]',
|
|
143
|
+
'visual_kind': 'decorative',
|
|
144
|
+
}],
|
|
145
|
+
}
|
|
146
|
+
decisions, asset_ids = apply_asset_decisions(manifest)
|
|
147
|
+
draft = """names:
|
|
148
|
+
layout-1: 内容页
|
|
149
|
+
roles:
|
|
150
|
+
layout-1: content
|
|
151
|
+
layout_modes:
|
|
152
|
+
layout-1: flow
|
|
153
|
+
layouts:
|
|
154
|
+
layout-1:
|
|
155
|
+
flow:
|
|
156
|
+
top: 100
|
|
157
|
+
margin: [80, 80]
|
|
158
|
+
gap: 40
|
|
159
|
+
regions:
|
|
160
|
+
- kind: stack
|
|
161
|
+
gap: 20
|
|
162
|
+
items:
|
|
163
|
+
- {role: asset-candidate, type: pic, source_media: shared.png, source_box: [100, 200, 80, 80]}
|
|
164
|
+
slots:
|
|
165
|
+
- {role: asset-candidate, box: [100, 200, 80, 80], type: pic, source_media: shared.png}
|
|
166
|
+
confidence: high
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
layouts_md = build_layouts_md(
|
|
170
|
+
split_top_blocks(draft), (1920, 1080),
|
|
171
|
+
asset_decisions=decisions, asset_ids=asset_ids,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
self.assertIn('- {role: texture, type: pic, asset: texture-1}', layouts_md)
|
|
175
|
+
self.assertNotIn('source_media', layouts_md)
|
|
176
|
+
self.assertNotIn('source_box', layouts_md)
|
|
128
177
|
|
|
129
178
|
def test_layout_mode_rejects_unknown_value(self):
|
|
130
179
|
draft = """names:
|
|
@@ -262,14 +311,22 @@ layouts:
|
|
|
262
311
|
with open(os.path.join(output_dir, 'layouts.yaml'), encoding='utf-8') as stream:
|
|
263
312
|
layouts_yaml = stream.read()
|
|
264
313
|
|
|
265
|
-
self.
|
|
266
|
-
self.assertIn('
|
|
314
|
+
self.assertNotIn('\nlayout_modes:\n', layouts_yaml)
|
|
315
|
+
self.assertIn('默认保留 slots', layouts_yaml)
|
|
267
316
|
self.assertIn(' - kind: grid\n cols: 3', layouts_yaml)
|
|
268
317
|
self.assertEqual(layouts_yaml.count(' - role: group'), 3)
|
|
269
318
|
self.assertEqual(layouts_yaml.count(' items:'), 3)
|
|
270
319
|
self.assertEqual(layouts_yaml.count('role: container'), 3)
|
|
271
320
|
|
|
272
|
-
|
|
321
|
+
default_layouts_md = build_layouts_md(split_top_blocks(layouts_yaml), (1920, 1080))
|
|
322
|
+
|
|
323
|
+
self.assertNotIn(' flow:', default_layouts_md)
|
|
324
|
+
self.assertIn(' slots:', default_layouts_md)
|
|
325
|
+
|
|
326
|
+
decided = layouts_yaml.replace(
|
|
327
|
+
'layouts:',
|
|
328
|
+
'layout_modes:\n layout-1: flow\nlayouts:',
|
|
329
|
+
)
|
|
273
330
|
layouts_md = build_layouts_md(split_top_blocks(decided), (1920, 1080))
|
|
274
331
|
|
|
275
332
|
self.assertNotIn('layout_modes:', layouts_md)
|
|
@@ -324,6 +381,99 @@ layouts:
|
|
|
324
381
|
['group', 'group', 'group'],
|
|
325
382
|
)
|
|
326
383
|
|
|
384
|
+
def test_centered_card_grid_carries_its_own_symmetric_margin(self):
|
|
385
|
+
# 短标题贴左(x=120..900),三张卡片居中(左右各 360)。整页 margin 取「最左槽 +
|
|
386
|
+
# 最右槽」的外包络 = [120, 360],直接套给居中卡片组会把它拉偏成左对齐(左缝小、
|
|
387
|
+
# 右缝大)。卡片区带应带自己的对称 margin,还原居中。
|
|
388
|
+
archetype = {
|
|
389
|
+
'name': 'layout-1',
|
|
390
|
+
'zh': '标题加三列卡片',
|
|
391
|
+
'role': 'content',
|
|
392
|
+
'bg': None,
|
|
393
|
+
'slots': [
|
|
394
|
+
text_slot('title', [120, 73, 780, 68], '核心能力'),
|
|
395
|
+
],
|
|
396
|
+
'decor': [],
|
|
397
|
+
'pages': [2],
|
|
398
|
+
'rep': 2,
|
|
399
|
+
'pic_n': 0,
|
|
400
|
+
'confidence': 'high',
|
|
401
|
+
}
|
|
402
|
+
for left in (360, 760, 1160):
|
|
403
|
+
archetype['decor'].append({
|
|
404
|
+
'box': [left, 430, 400, 300],
|
|
405
|
+
'geom': 'rect',
|
|
406
|
+
'css': 'background: rgba(255,255,255,0.7)',
|
|
407
|
+
})
|
|
408
|
+
for top in (490, 570):
|
|
409
|
+
archetype['slots'].append(
|
|
410
|
+
text_slot('body', [left + 40, top, 320, 41], '卡片内容'),
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
flow = draft_flow(archetype, {}, (1920, 1080))
|
|
414
|
+
|
|
415
|
+
self.assertIsNotNone(flow)
|
|
416
|
+
self.assertEqual([region['kind'] for region in flow['regions']], ['stack', 'grid'])
|
|
417
|
+
self.assertEqual(flow['margin'], [120, 360])
|
|
418
|
+
grid = flow['regions'][1]
|
|
419
|
+
self.assertEqual(grid.get('margin'), [360, 360])
|
|
420
|
+
|
|
421
|
+
archetype['flow'] = flow
|
|
422
|
+
with tempfile.TemporaryDirectory() as output_dir:
|
|
423
|
+
emit_layouts([archetype], output_dir)
|
|
424
|
+
with open(os.path.join(output_dir, 'layouts.yaml'), encoding='utf-8') as stream:
|
|
425
|
+
layouts_yaml = stream.read()
|
|
426
|
+
decided = layouts_yaml.replace(
|
|
427
|
+
'layouts:',
|
|
428
|
+
'layout_modes:\n layout-1: flow\nlayouts:',
|
|
429
|
+
)
|
|
430
|
+
layouts_md = build_layouts_md(split_top_blocks(decided), (1920, 1080))
|
|
431
|
+
self.assertRegex(layouts_md, r'- kind: grid\n\s+cols: 3\n\s+gap: \[\d+, \d+\]\n\s+margin: \[360, 360\]')
|
|
432
|
+
|
|
433
|
+
def test_symmetric_card_grid_omits_region_margin(self):
|
|
434
|
+
# 卡片组横向范围已和整页一致(都对称),不该冒出多余的区带 margin——保证对已有
|
|
435
|
+
# 对称模板零回归、layouts.md 不膨胀。
|
|
436
|
+
archetype = {
|
|
437
|
+
'name': 'layout-1',
|
|
438
|
+
'zh': '标题加三列卡片',
|
|
439
|
+
'role': 'content',
|
|
440
|
+
'bg': None,
|
|
441
|
+
'slots': [
|
|
442
|
+
text_slot('title', [120, 73, 1680, 68], '核心能力'),
|
|
443
|
+
],
|
|
444
|
+
'decor': [],
|
|
445
|
+
'pages': [2],
|
|
446
|
+
'rep': 2,
|
|
447
|
+
'pic_n': 0,
|
|
448
|
+
'confidence': 'high',
|
|
449
|
+
}
|
|
450
|
+
for left in (120, 720, 1320):
|
|
451
|
+
archetype['decor'].append({
|
|
452
|
+
'box': [left, 430, 480, 300],
|
|
453
|
+
'geom': 'rect',
|
|
454
|
+
'css': 'background: rgba(255,255,255,0.7)',
|
|
455
|
+
})
|
|
456
|
+
for top in (490, 570):
|
|
457
|
+
archetype['slots'].append(
|
|
458
|
+
text_slot('body', [left + 40, top, 400, 41], '卡片内容'),
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
flow = draft_flow(archetype, {}, (1920, 1080))
|
|
462
|
+
|
|
463
|
+
self.assertIsNotNone(flow)
|
|
464
|
+
self.assertEqual(flow['margin'], [120, 120])
|
|
465
|
+
grid = flow['regions'][1]
|
|
466
|
+
self.assertEqual(grid['kind'], 'grid')
|
|
467
|
+
self.assertNotIn('margin', grid)
|
|
468
|
+
|
|
469
|
+
archetype['flow'] = flow
|
|
470
|
+
with tempfile.TemporaryDirectory() as output_dir:
|
|
471
|
+
emit_layouts([archetype], output_dir)
|
|
472
|
+
with open(os.path.join(output_dir, 'layouts.yaml'), encoding='utf-8') as stream:
|
|
473
|
+
layouts_yaml = stream.read()
|
|
474
|
+
grid_block = layouts_yaml.split('- kind: grid', 1)[1].split('items:', 1)[0]
|
|
475
|
+
self.assertNotIn('margin:', grid_block)
|
|
476
|
+
|
|
327
477
|
def test_fixed_template_anchors_stay_in_a_positioned_free_region(self):
|
|
328
478
|
archetype = {
|
|
329
479
|
'name': 'layout-1',
|