@lark-apaas/coding-steering 0.1.18-dev.e6a2787 → 0.1.18-dev.ec7b8c1
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 +4 -3
- package/steering/design-html/skills/pptx-style-extract/scripts/check_v2.py +33 -3
- package/steering/design-html/skills/pptx-style-extract/scripts/draft.py +404 -82
- package/steering/design-html/skills/pptx-style-extract/scripts/package.py +146 -24
- package/steering/design-html/skills/pptx-style-extract/scripts/query.py +3 -8
- package/steering/design-html/skills/pptx-style-extract/scripts/test_color_contract.py +60 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/test_design_consumer_contract.py +63 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/test_flow_layout_contract.py +468 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/test_layout_css.py +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/scripts/test_text_role_contract.py +168 -0
- package/steering/design-html/skills/pptx-style-extract/v2-format-spec.md +13 -6
- 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
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Regression tests for model-selected flow layouts and grouped card columns."""
|
|
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 draft import draft_flow, draft_layouts, emit_layouts
|
|
11
|
+
from check_v2 import Pack, rule_v2_14, rule_v2_16
|
|
12
|
+
from package import Fail, build_layouts_md, split_top_blocks
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def text_slot(role, box, text):
|
|
16
|
+
return {
|
|
17
|
+
'role': role,
|
|
18
|
+
'type': 'title' if role == 'title' else 'body',
|
|
19
|
+
'box': box,
|
|
20
|
+
'sz': 36,
|
|
21
|
+
'txt': text,
|
|
22
|
+
'css': 'font-size: 36px',
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class FlowLayoutContractTest(unittest.TestCase):
|
|
27
|
+
def test_sample_page_keeps_every_text_slot_for_card_grouping(self):
|
|
28
|
+
slide_part = 'ppt/slides/slide2.xml'
|
|
29
|
+
layout_part = 'ppt/slideLayouts/slideLayout2.xml'
|
|
30
|
+
shapes = []
|
|
31
|
+
for index in range(8):
|
|
32
|
+
shapes.append({
|
|
33
|
+
'part': slide_part,
|
|
34
|
+
'layer': 'slide',
|
|
35
|
+
'id': str(index + 1),
|
|
36
|
+
'kind': 'sp',
|
|
37
|
+
'name': 'Text',
|
|
38
|
+
'ph': None,
|
|
39
|
+
'box': {'x': 100 + (index % 3) * 560, 'y': 100 + index * 80,
|
|
40
|
+
'w': 480, 'h': 60},
|
|
41
|
+
'text': {
|
|
42
|
+
'bodyPr': {},
|
|
43
|
+
'lstStyle': {'lvl1pPr': {'sz_px': 32}},
|
|
44
|
+
'paragraphs': [{'runs': [{'text': 'Text %d' % index}]}],
|
|
45
|
+
},
|
|
46
|
+
})
|
|
47
|
+
data = {
|
|
48
|
+
'canvas': {'px': [1920, 1080]},
|
|
49
|
+
'form_hint': {'form': 0},
|
|
50
|
+
'slides': [{'part': slide_part, 'layout': layout_part, 'background': None}],
|
|
51
|
+
'background_composites': {},
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
with tempfile.TemporaryDirectory() as output_dir:
|
|
55
|
+
os.makedirs(os.path.join(output_dir, 'ref'))
|
|
56
|
+
with open(os.path.join(output_dir, 'ref', 'shapes.json'), 'w',
|
|
57
|
+
encoding='utf-8') as stream:
|
|
58
|
+
import json
|
|
59
|
+
json.dump({'shapes': shapes}, stream)
|
|
60
|
+
archetypes, _, _ = draft_layouts(data, output_dir)
|
|
61
|
+
|
|
62
|
+
self.assertEqual(len(archetypes[0]['slots']), 8)
|
|
63
|
+
|
|
64
|
+
def test_layout_mode_selects_flow_without_leaking_draft_control(self):
|
|
65
|
+
draft = """names:
|
|
66
|
+
layout-1: 内容页
|
|
67
|
+
roles:
|
|
68
|
+
layout-1: content
|
|
69
|
+
layout_modes:
|
|
70
|
+
layout-1: flow
|
|
71
|
+
layouts:
|
|
72
|
+
layout-1:
|
|
73
|
+
flow:
|
|
74
|
+
top: 100
|
|
75
|
+
margin: [80, 80]
|
|
76
|
+
gap: 40
|
|
77
|
+
regions:
|
|
78
|
+
- kind: stack
|
|
79
|
+
gap: 20
|
|
80
|
+
items:
|
|
81
|
+
- {role: title, type: title}
|
|
82
|
+
- {role: body, type: body}
|
|
83
|
+
slots:
|
|
84
|
+
- {role: title, box: [80, 100, 1760, 80], type: title}
|
|
85
|
+
- {role: body, box: [80, 220, 1760, 400], type: body}
|
|
86
|
+
confidence: high
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
layouts_md = build_layouts_md(split_top_blocks(draft), (1920, 1080))
|
|
90
|
+
|
|
91
|
+
self.assertIn(' flow:', layouts_md)
|
|
92
|
+
self.assertNotIn(' slots:', layouts_md)
|
|
93
|
+
self.assertNotIn('layout_modes:', layouts_md)
|
|
94
|
+
|
|
95
|
+
slots_md = build_layouts_md(
|
|
96
|
+
split_top_blocks(draft.replace('layout-1: flow', 'layout-1: slots')),
|
|
97
|
+
(1920, 1080),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
self.assertIn(' slots:', slots_md)
|
|
101
|
+
self.assertNotIn(' flow:', slots_md)
|
|
102
|
+
self.assertNotIn('layout_modes:', slots_md)
|
|
103
|
+
|
|
104
|
+
def test_layout_mode_is_required_when_both_forms_exist(self):
|
|
105
|
+
draft = """names:
|
|
106
|
+
layout-1: 内容页
|
|
107
|
+
roles:
|
|
108
|
+
layout-1: content
|
|
109
|
+
layouts:
|
|
110
|
+
layout-1:
|
|
111
|
+
flow:
|
|
112
|
+
top: 100
|
|
113
|
+
margin: [80, 80]
|
|
114
|
+
gap: 40
|
|
115
|
+
regions:
|
|
116
|
+
- kind: stack
|
|
117
|
+
items:
|
|
118
|
+
- {role: title, type: title}
|
|
119
|
+
- {role: body, type: body}
|
|
120
|
+
slots:
|
|
121
|
+
- {role: title, box: [80, 100, 1760, 80], type: title}
|
|
122
|
+
- {role: body, box: [80, 220, 1760, 400], type: body}
|
|
123
|
+
confidence: high
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
with self.assertRaisesRegex(Fail, 'layout-1 缺 layout_modes 判断'):
|
|
127
|
+
build_layouts_md(split_top_blocks(draft), (1920, 1080))
|
|
128
|
+
|
|
129
|
+
def test_layout_mode_rejects_unknown_value(self):
|
|
130
|
+
draft = """names:
|
|
131
|
+
layout-1: 内容页
|
|
132
|
+
roles:
|
|
133
|
+
layout-1: content
|
|
134
|
+
layout_modes:
|
|
135
|
+
layout-1: auto
|
|
136
|
+
layouts:
|
|
137
|
+
layout-1:
|
|
138
|
+
flow:
|
|
139
|
+
top: 100
|
|
140
|
+
margin: [80, 80]
|
|
141
|
+
gap: 40
|
|
142
|
+
regions:
|
|
143
|
+
- kind: stack
|
|
144
|
+
items:
|
|
145
|
+
- {role: title, type: title}
|
|
146
|
+
- {role: body, type: body}
|
|
147
|
+
slots:
|
|
148
|
+
- {role: title, box: [80, 100, 1760, 80], type: title}
|
|
149
|
+
- {role: body, box: [80, 220, 1760, 400], type: body}
|
|
150
|
+
confidence: high
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
with self.assertRaisesRegex(Fail, "layout-1 的 layout_modes 判断是 'auto'"):
|
|
154
|
+
build_layouts_md(split_top_blocks(draft), (1920, 1080))
|
|
155
|
+
|
|
156
|
+
def test_model_identified_footer_moves_to_positioned_free_region(self):
|
|
157
|
+
draft = """names:
|
|
158
|
+
layout-1: 内容页
|
|
159
|
+
roles:
|
|
160
|
+
layout-1: content
|
|
161
|
+
text_roles:
|
|
162
|
+
layout-1-text-1: title
|
|
163
|
+
layout-1-text-2: body
|
|
164
|
+
layout-1-text-3: footer
|
|
165
|
+
layout_modes:
|
|
166
|
+
layout-1: flow
|
|
167
|
+
layouts:
|
|
168
|
+
layout-1:
|
|
169
|
+
flow:
|
|
170
|
+
top: 100
|
|
171
|
+
margin: [80, 80]
|
|
172
|
+
gap: 40
|
|
173
|
+
regions:
|
|
174
|
+
- kind: stack
|
|
175
|
+
gap: 20
|
|
176
|
+
items:
|
|
177
|
+
# text-role: layout-1-text-1
|
|
178
|
+
- {role: body, type: body}
|
|
179
|
+
# text-role: layout-1-text-2
|
|
180
|
+
- {role: body, type: body}
|
|
181
|
+
# text-role: layout-1-text-3
|
|
182
|
+
- {role: body, type: body}
|
|
183
|
+
slots:
|
|
184
|
+
# text-role: layout-1-text-1
|
|
185
|
+
- {role: body, box: [80, 100, 1760, 80], type: body}
|
|
186
|
+
# text-role: layout-1-text-2
|
|
187
|
+
- {role: body, box: [80, 220, 1760, 400], type: body}
|
|
188
|
+
# text-role: layout-1-text-3
|
|
189
|
+
- {role: body, box: [80, 1000, 1760, 40], type: body}
|
|
190
|
+
confidence: high
|
|
191
|
+
"""
|
|
192
|
+
|
|
193
|
+
layouts_md = build_layouts_md(split_top_blocks(draft), (1920, 1080))
|
|
194
|
+
|
|
195
|
+
self.assertIn(' - kind: free', layouts_md)
|
|
196
|
+
self.assertIn(
|
|
197
|
+
'- {role: footer, box: [80, 1000, 1760, 40], type: footer}',
|
|
198
|
+
layouts_md,
|
|
199
|
+
)
|
|
200
|
+
stack = layouts_md.split(' - kind: stack', 1)[1].split(
|
|
201
|
+
' - kind: free', 1,
|
|
202
|
+
)[0]
|
|
203
|
+
self.assertNotIn('role: footer', stack)
|
|
204
|
+
|
|
205
|
+
def test_multicolumn_cards_emit_one_level_groups(self):
|
|
206
|
+
archetype = {
|
|
207
|
+
'name': 'layout-1',
|
|
208
|
+
'zh': '三列问题页',
|
|
209
|
+
'role': 'content',
|
|
210
|
+
'bg': None,
|
|
211
|
+
'slots': [
|
|
212
|
+
text_slot('title', [120, 80, 1680, 80], '三大关键问题'),
|
|
213
|
+
text_slot('summary', [120, 200, 1680, 80], '本季度核心问题摘要'),
|
|
214
|
+
text_slot('card-title', [150, 360, 480, 60], '毛利承压'),
|
|
215
|
+
text_slot('body', [150, 440, 480, 180], '毛利率低于目标'),
|
|
216
|
+
text_slot('card-title', [720, 360, 480, 60], '收入延期'),
|
|
217
|
+
text_slot('body', [720, 440, 480, 180], '重点项目验收延后'),
|
|
218
|
+
text_slot('card-title', [1290, 360, 480, 60], '续约下滑'),
|
|
219
|
+
text_slot('body', [1290, 440, 480, 180], '中小客户流失增加'),
|
|
220
|
+
],
|
|
221
|
+
'decor': [
|
|
222
|
+
{
|
|
223
|
+
'box': [120, 330, 540, 420],
|
|
224
|
+
'geom': 'rect',
|
|
225
|
+
'css': 'background: #FFFFFF; border: 1px solid #DDE7F2',
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
'box': [690, 330, 540, 420],
|
|
229
|
+
'geom': 'rect',
|
|
230
|
+
'css': 'background: #FFFFFF; border: 1px solid #DDE7F2',
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
'box': [1260, 330, 540, 420],
|
|
234
|
+
'geom': 'rect',
|
|
235
|
+
'css': 'background: #FFFFFF; border: 1px solid #DDE7F2',
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
'box': [115, 660, 550, 95],
|
|
239
|
+
'geom': 'rect',
|
|
240
|
+
'css': 'background: #0A84FF',
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
'box': [685, 660, 550, 95],
|
|
244
|
+
'geom': 'rect',
|
|
245
|
+
'css': 'background: #0A84FF',
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
'box': [1255, 660, 550, 95],
|
|
249
|
+
'geom': 'rect',
|
|
250
|
+
'css': 'background: #0A84FF',
|
|
251
|
+
},
|
|
252
|
+
],
|
|
253
|
+
'pages': [2],
|
|
254
|
+
'rep': 2,
|
|
255
|
+
'pic_n': 0,
|
|
256
|
+
'confidence': 'medium',
|
|
257
|
+
}
|
|
258
|
+
archetype['flow'] = draft_flow(archetype, {}, (1920, 1080))
|
|
259
|
+
|
|
260
|
+
with tempfile.TemporaryDirectory() as output_dir:
|
|
261
|
+
emit_layouts([archetype], output_dir)
|
|
262
|
+
with open(os.path.join(output_dir, 'layouts.yaml'), encoding='utf-8') as stream:
|
|
263
|
+
layouts_yaml = stream.read()
|
|
264
|
+
|
|
265
|
+
self.assertIn('layout_modes:', layouts_yaml)
|
|
266
|
+
self.assertIn(' layout-1: TODO布局模式', layouts_yaml)
|
|
267
|
+
self.assertIn(' - kind: grid\n cols: 3', layouts_yaml)
|
|
268
|
+
self.assertEqual(layouts_yaml.count(' - role: group'), 3)
|
|
269
|
+
self.assertEqual(layouts_yaml.count(' items:'), 3)
|
|
270
|
+
self.assertEqual(layouts_yaml.count('role: container'), 3)
|
|
271
|
+
|
|
272
|
+
decided = layouts_yaml.replace('TODO布局模式', 'flow')
|
|
273
|
+
layouts_md = build_layouts_md(split_top_blocks(decided), (1920, 1080))
|
|
274
|
+
|
|
275
|
+
self.assertNotIn('layout_modes:', layouts_md)
|
|
276
|
+
self.assertNotIn(' slots:', layouts_md)
|
|
277
|
+
self.assertIn(' flow:', layouts_md)
|
|
278
|
+
self.assertEqual(layouts_md.count(' - role: group'), 3)
|
|
279
|
+
self.assertEqual(layouts_md.count(' items:'), 3)
|
|
280
|
+
|
|
281
|
+
with tempfile.TemporaryDirectory() as pack_dir:
|
|
282
|
+
with open(os.path.join(pack_dir, 'design.md'), 'w', encoding='utf-8') as stream:
|
|
283
|
+
stream.write('---\nversion: alpha\nlayouts: layouts.md\n---\n')
|
|
284
|
+
with open(os.path.join(pack_dir, 'layouts.md'), 'w', encoding='utf-8') as stream:
|
|
285
|
+
stream.write(layouts_md)
|
|
286
|
+
pack = Pack(pack_dir)
|
|
287
|
+
|
|
288
|
+
self.assertEqual(rule_v2_14(pack).level, 'PASS')
|
|
289
|
+
self.assertEqual(rule_v2_16(pack).level, 'PASS')
|
|
290
|
+
|
|
291
|
+
def test_title_and_single_card_row_gap_form_two_flow_regions(self):
|
|
292
|
+
archetype = {
|
|
293
|
+
'name': 'layout-1',
|
|
294
|
+
'zh': '标题加三列卡片',
|
|
295
|
+
'role': 'content',
|
|
296
|
+
'bg': None,
|
|
297
|
+
'slots': [
|
|
298
|
+
text_slot('title', [228, 73, 1465, 68], '核心能力'),
|
|
299
|
+
],
|
|
300
|
+
'decor': [],
|
|
301
|
+
'pages': [2],
|
|
302
|
+
'rep': 2,
|
|
303
|
+
'pic_n': 0,
|
|
304
|
+
'confidence': 'high',
|
|
305
|
+
}
|
|
306
|
+
for left in (70, 671, 1270):
|
|
307
|
+
archetype['decor'].append({
|
|
308
|
+
'box': [left, 212, 580, 660],
|
|
309
|
+
'geom': 'rect',
|
|
310
|
+
'css': 'background: rgba(255,255,255,0.7)',
|
|
311
|
+
})
|
|
312
|
+
for top in (270, 374, 498, 621, 797):
|
|
313
|
+
archetype['slots'].append(
|
|
314
|
+
text_slot('body', [left + 80, top, 405, 41], '卡片内容'),
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
flow = draft_flow(archetype, {}, (1920, 1080))
|
|
318
|
+
|
|
319
|
+
self.assertIsNotNone(flow)
|
|
320
|
+
self.assertEqual([region['kind'] for region in flow['regions']], ['stack', 'grid'])
|
|
321
|
+
self.assertEqual(flow['regions'][1]['cols'], 3)
|
|
322
|
+
self.assertEqual(
|
|
323
|
+
[item['role'] for item in flow['regions'][1]['items']],
|
|
324
|
+
['group', 'group', 'group'],
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
def test_centered_card_grid_carries_its_own_symmetric_margin(self):
|
|
328
|
+
# 短标题贴左(x=120..900),三张卡片居中(左右各 360)。整页 margin 取「最左槽 +
|
|
329
|
+
# 最右槽」的外包络 = [120, 360],直接套给居中卡片组会把它拉偏成左对齐(左缝小、
|
|
330
|
+
# 右缝大)。卡片区带应带自己的对称 margin,还原居中。
|
|
331
|
+
archetype = {
|
|
332
|
+
'name': 'layout-1',
|
|
333
|
+
'zh': '标题加三列卡片',
|
|
334
|
+
'role': 'content',
|
|
335
|
+
'bg': None,
|
|
336
|
+
'slots': [
|
|
337
|
+
text_slot('title', [120, 73, 780, 68], '核心能力'),
|
|
338
|
+
],
|
|
339
|
+
'decor': [],
|
|
340
|
+
'pages': [2],
|
|
341
|
+
'rep': 2,
|
|
342
|
+
'pic_n': 0,
|
|
343
|
+
'confidence': 'high',
|
|
344
|
+
}
|
|
345
|
+
for left in (360, 760, 1160):
|
|
346
|
+
archetype['decor'].append({
|
|
347
|
+
'box': [left, 430, 400, 300],
|
|
348
|
+
'geom': 'rect',
|
|
349
|
+
'css': 'background: rgba(255,255,255,0.7)',
|
|
350
|
+
})
|
|
351
|
+
for top in (490, 570):
|
|
352
|
+
archetype['slots'].append(
|
|
353
|
+
text_slot('body', [left + 40, top, 320, 41], '卡片内容'),
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
flow = draft_flow(archetype, {}, (1920, 1080))
|
|
357
|
+
|
|
358
|
+
self.assertIsNotNone(flow)
|
|
359
|
+
self.assertEqual([region['kind'] for region in flow['regions']], ['stack', 'grid'])
|
|
360
|
+
self.assertEqual(flow['margin'], [120, 360])
|
|
361
|
+
grid = flow['regions'][1]
|
|
362
|
+
self.assertEqual(grid.get('margin'), [360, 360])
|
|
363
|
+
|
|
364
|
+
archetype['flow'] = flow
|
|
365
|
+
with tempfile.TemporaryDirectory() as output_dir:
|
|
366
|
+
emit_layouts([archetype], output_dir)
|
|
367
|
+
with open(os.path.join(output_dir, 'layouts.yaml'), encoding='utf-8') as stream:
|
|
368
|
+
layouts_yaml = stream.read()
|
|
369
|
+
decided = layouts_yaml.replace('TODO布局模式', 'flow')
|
|
370
|
+
layouts_md = build_layouts_md(split_top_blocks(decided), (1920, 1080))
|
|
371
|
+
self.assertRegex(layouts_md, r'- kind: grid\n\s+cols: 3\n\s+gap: \[\d+, \d+\]\n\s+margin: \[360, 360\]')
|
|
372
|
+
|
|
373
|
+
def test_symmetric_card_grid_omits_region_margin(self):
|
|
374
|
+
# 卡片组横向范围已和整页一致(都对称),不该冒出多余的区带 margin——保证对已有
|
|
375
|
+
# 对称模板零回归、layouts.md 不膨胀。
|
|
376
|
+
archetype = {
|
|
377
|
+
'name': 'layout-1',
|
|
378
|
+
'zh': '标题加三列卡片',
|
|
379
|
+
'role': 'content',
|
|
380
|
+
'bg': None,
|
|
381
|
+
'slots': [
|
|
382
|
+
text_slot('title', [120, 73, 1680, 68], '核心能力'),
|
|
383
|
+
],
|
|
384
|
+
'decor': [],
|
|
385
|
+
'pages': [2],
|
|
386
|
+
'rep': 2,
|
|
387
|
+
'pic_n': 0,
|
|
388
|
+
'confidence': 'high',
|
|
389
|
+
}
|
|
390
|
+
for left in (120, 720, 1320):
|
|
391
|
+
archetype['decor'].append({
|
|
392
|
+
'box': [left, 430, 480, 300],
|
|
393
|
+
'geom': 'rect',
|
|
394
|
+
'css': 'background: rgba(255,255,255,0.7)',
|
|
395
|
+
})
|
|
396
|
+
for top in (490, 570):
|
|
397
|
+
archetype['slots'].append(
|
|
398
|
+
text_slot('body', [left + 40, top, 400, 41], '卡片内容'),
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
flow = draft_flow(archetype, {}, (1920, 1080))
|
|
402
|
+
|
|
403
|
+
self.assertIsNotNone(flow)
|
|
404
|
+
self.assertEqual(flow['margin'], [120, 120])
|
|
405
|
+
grid = flow['regions'][1]
|
|
406
|
+
self.assertEqual(grid['kind'], 'grid')
|
|
407
|
+
self.assertNotIn('margin', grid)
|
|
408
|
+
|
|
409
|
+
archetype['flow'] = flow
|
|
410
|
+
with tempfile.TemporaryDirectory() as output_dir:
|
|
411
|
+
emit_layouts([archetype], output_dir)
|
|
412
|
+
with open(os.path.join(output_dir, 'layouts.yaml'), encoding='utf-8') as stream:
|
|
413
|
+
layouts_yaml = stream.read()
|
|
414
|
+
grid_block = layouts_yaml.split('- kind: grid', 1)[1].split('items:', 1)[0]
|
|
415
|
+
self.assertNotIn('margin:', grid_block)
|
|
416
|
+
|
|
417
|
+
def test_fixed_template_anchors_stay_in_a_positioned_free_region(self):
|
|
418
|
+
archetype = {
|
|
419
|
+
'name': 'layout-1',
|
|
420
|
+
'zh': '内容页',
|
|
421
|
+
'role': 'content',
|
|
422
|
+
'bg': None,
|
|
423
|
+
'slots': [
|
|
424
|
+
text_slot('title', [120, 100, 1680, 80], '标题'),
|
|
425
|
+
text_slot('body', [120, 260, 1680, 280], '正文'),
|
|
426
|
+
{
|
|
427
|
+
'role': 'logo',
|
|
428
|
+
'type': 'pic',
|
|
429
|
+
'box': [1740, 40, 100, 60],
|
|
430
|
+
'sz': 0,
|
|
431
|
+
'txt': '',
|
|
432
|
+
'asset': 'logo-primary',
|
|
433
|
+
},
|
|
434
|
+
{
|
|
435
|
+
'role': 'slide-number',
|
|
436
|
+
'type': 'slide-number',
|
|
437
|
+
'box': [1780, 1000, 60, 40],
|
|
438
|
+
'sz': 24,
|
|
439
|
+
'txt': '2',
|
|
440
|
+
'css': 'font-size: 24px',
|
|
441
|
+
},
|
|
442
|
+
],
|
|
443
|
+
'decor': [],
|
|
444
|
+
'pages': [2],
|
|
445
|
+
'rep': 2,
|
|
446
|
+
'pic_n': 1,
|
|
447
|
+
'confidence': 'medium',
|
|
448
|
+
}
|
|
449
|
+
archetype['flow'] = draft_flow(archetype, {}, (1920, 1080))
|
|
450
|
+
|
|
451
|
+
with tempfile.TemporaryDirectory() as output_dir:
|
|
452
|
+
emit_layouts([archetype], output_dir)
|
|
453
|
+
with open(os.path.join(output_dir, 'layouts.yaml'), encoding='utf-8') as stream:
|
|
454
|
+
layouts_yaml = stream.read()
|
|
455
|
+
|
|
456
|
+
self.assertIn(' - kind: free', layouts_yaml)
|
|
457
|
+
self.assertIn(
|
|
458
|
+
'- {role: logo, type: pic, box: [1740, 40, 100, 60], asset: logo-primary}',
|
|
459
|
+
layouts_yaml,
|
|
460
|
+
)
|
|
461
|
+
self.assertIn(
|
|
462
|
+
'- {role: slide-number, type: slide-number, box: [1780, 1000, 60, 40]',
|
|
463
|
+
layouts_yaml,
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
if __name__ == '__main__':
|
|
468
|
+
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()
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Regression tests for preserving per-container radii."""
|
|
3
|
+
import os
|
|
4
|
+
import tempfile
|
|
5
|
+
import unittest
|
|
6
|
+
|
|
7
|
+
from draft import emit_body, emit_frontmatter
|
|
8
|
+
from query import _recipe_css
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def render_frontmatter(radii):
|
|
12
|
+
data = {
|
|
13
|
+
'radii_census': radii,
|
|
14
|
+
'spacing_candidates': {},
|
|
15
|
+
}
|
|
16
|
+
tokens = [('surface', {'hex': '#FFFFFF'})]
|
|
17
|
+
|
|
18
|
+
with tempfile.TemporaryDirectory() as output_dir:
|
|
19
|
+
emit_frontmatter(
|
|
20
|
+
data,
|
|
21
|
+
tokens,
|
|
22
|
+
[],
|
|
23
|
+
{},
|
|
24
|
+
[],
|
|
25
|
+
[],
|
|
26
|
+
output_dir,
|
|
27
|
+
)
|
|
28
|
+
with open(os.path.join(output_dir, 'frontmatter.yaml'), encoding='utf-8') as stream:
|
|
29
|
+
return stream.read()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def render_body():
|
|
33
|
+
data = {
|
|
34
|
+
'canvas': {'px': [1920, 1080]},
|
|
35
|
+
'form_hint': {'form': 2},
|
|
36
|
+
'counts': {'slides': 1},
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
with tempfile.TemporaryDirectory() as output_dir:
|
|
40
|
+
emit_body(
|
|
41
|
+
data,
|
|
42
|
+
[],
|
|
43
|
+
[],
|
|
44
|
+
{},
|
|
45
|
+
[],
|
|
46
|
+
[],
|
|
47
|
+
[],
|
|
48
|
+
{},
|
|
49
|
+
output_dir,
|
|
50
|
+
)
|
|
51
|
+
with open(os.path.join(output_dir, 'body.md'), encoding='utf-8') as stream:
|
|
52
|
+
return stream.read()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class RoundedContractTest(unittest.TestCase):
|
|
56
|
+
def test_multiple_radius_tiers_are_not_collapsed_into_one_card_token(self):
|
|
57
|
+
frontmatter = render_frontmatter(
|
|
58
|
+
[
|
|
59
|
+
{'px': 6.9, 'n': 2},
|
|
60
|
+
{'px': 11.9, 'n': 2},
|
|
61
|
+
{'px': 14.4, 'n': 3},
|
|
62
|
+
]
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
self.assertNotIn('rounded:', frontmatter)
|
|
66
|
+
|
|
67
|
+
def test_single_radius_tier_can_remain_a_global_token(self):
|
|
68
|
+
frontmatter = render_frontmatter([{'px': 12, 'n': 9}])
|
|
69
|
+
|
|
70
|
+
self.assertIn('rounded:\n card: 12px', frontmatter)
|
|
71
|
+
|
|
72
|
+
def test_rare_rounded_exceptions_do_not_override_a_zero_radius_majority(self):
|
|
73
|
+
frontmatter = render_frontmatter(
|
|
74
|
+
[
|
|
75
|
+
{'px': 0, 'n': 241},
|
|
76
|
+
{'px': 3.4, 'n': 4},
|
|
77
|
+
{'px': 6.9, 'n': 3},
|
|
78
|
+
{'px': 50.5, 'n': 1},
|
|
79
|
+
]
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
self.assertNotIn('rounded:', frontmatter)
|
|
83
|
+
|
|
84
|
+
def test_generated_usage_defaults_unspecified_container_radius_to_zero(self):
|
|
85
|
+
body = render_body()
|
|
86
|
+
|
|
87
|
+
self.assertIn('没有 `border-radius` 就按 `0`', body)
|
|
88
|
+
self.assertIn('不得自行补圆角', body)
|
|
89
|
+
|
|
90
|
+
def test_recipe_does_not_promote_one_rounded_exception_to_the_whole_group(self):
|
|
91
|
+
css = _recipe_css(
|
|
92
|
+
{'type': 'solid', 'color': {'hex': '#FFFFFF'}},
|
|
93
|
+
None,
|
|
94
|
+
[0] * 70 + [50.5],
|
|
95
|
+
None,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
self.assertFalse(any('border-radius' in declaration for declaration in css))
|
|
99
|
+
|
|
100
|
+
def test_recipe_keeps_a_radius_shared_by_the_whole_group(self):
|
|
101
|
+
css = _recipe_css(
|
|
102
|
+
{'type': 'solid', 'color': {'hex': '#FFFFFF'}},
|
|
103
|
+
None,
|
|
104
|
+
[6.9] * 17,
|
|
105
|
+
None,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
self.assertIn('border-radius: 6.9px', css)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
if __name__ == '__main__':
|
|
112
|
+
unittest.main()
|