@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.
Files changed (58) hide show
  1. package/package.json +6 -6
  2. package/steering/design-html/skills/charts/SKILL.md +4 -0
  3. package/steering/design-html/skills/pptx-style-extract/SKILL.md +64 -26
  4. package/steering/design-html/skills/pptx-style-extract/font-fallback.yaml +3 -3
  5. package/steering/design-html/skills/pptx-style-extract/scripts/census.py +18 -12
  6. package/steering/design-html/skills/pptx-style-extract/scripts/check_v2.py +153 -8
  7. package/steering/design-html/skills/pptx-style-extract/scripts/draft.py +2735 -288
  8. package/steering/design-html/skills/pptx-style-extract/scripts/extract.py +325 -22
  9. package/steering/design-html/skills/pptx-style-extract/scripts/ooxml.py +19 -2
  10. package/steering/design-html/skills/pptx-style-extract/scripts/package.py +991 -165
  11. package/steering/design-html/skills/pptx-style-extract/scripts/parts.py +6 -3
  12. package/steering/design-html/skills/pptx-style-extract/scripts/query.py +4 -9
  13. package/steering/design-html/skills/pptx-style-extract/scripts/render_pages.py +16 -10
  14. package/steering/design-html/skills/pptx-style-extract/scripts/test_asset_judgment_package.py +443 -0
  15. package/steering/design-html/skills/pptx-style-extract/scripts/test_background_composite.py +57 -0
  16. package/steering/design-html/skills/pptx-style-extract/scripts/test_color_contract.py +60 -0
  17. package/steering/design-html/skills/pptx-style-extract/scripts/test_design_consumer_contract.py +63 -0
  18. package/steering/design-html/skills/pptx-style-extract/scripts/test_flow_layout_contract.py +528 -0
  19. package/steering/design-html/skills/pptx-style-extract/scripts/test_layout_css.py +1513 -0
  20. package/steering/design-html/skills/pptx-style-extract/scripts/test_rounded_contract.py +112 -0
  21. package/steering/design-html/skills/pptx-style-extract/scripts/test_text_role_contract.py +315 -0
  22. package/steering/design-html/skills/pptx-style-extract/v2-format-spec.md +27 -15
  23. package/steering/design-html/skills/preflight/scripts/probe.sh +0 -0
  24. package/steering/nestjs-react-fullstack/skills/app-init-feasibility-guide/SKILL.md +1 -0
  25. package/steering/nestjs-react-fullstack/skills/authn-guide/SKILL.md +6 -0
  26. package/steering/nestjs-react-fullstack/skills/authz-guide/SKILL.md +5 -5
  27. package/steering/nestjs-react-fullstack/skills/authz-guide/references/dynamic-permission-guide.md +1 -1
  28. package/steering/nestjs-react-fullstack/skills/client-builtins-file-storage-service/SKILL.md +37 -113
  29. package/steering/nestjs-react-fullstack/skills/client-builtins-user-service/SKILL.md +13 -2
  30. package/steering/nestjs-react-fullstack/skills/code-fix/SKILL.md +7 -7
  31. package/steering/nestjs-react-fullstack/skills/coding-guide/SKILL.md +149 -24
  32. package/steering/nestjs-react-fullstack/skills/connections-sdk/SKILL.md +202 -0
  33. package/steering/nestjs-react-fullstack/skills/nestjs-cache/SKILL.md +255 -0
  34. package/steering/nestjs-react-fullstack/skills/plugin-guide/SKILL.md +158 -543
  35. package/steering/nestjs-react-fullstack/skills/plugin-guide/references/plugin-coding-guide.md +15 -1
  36. package/steering/nestjs-react-fullstack/skills/plugin-guide/references/table.md +30 -14
  37. package/steering/nestjs-react-fullstack/skills/raw-sql-boundary-audit/SKILL.md +63 -0
  38. package/steering/nestjs-react-fullstack/skills/server-builtins-file-storage-service/SKILL.md +1 -1
  39. package/steering/nestjs-react-fullstack/skills_common/trigger-guide/SKILL.md +284 -12
  40. package/steering/nestjs-react-fullstack/skills_local/plugin-guide/SKILL.md +4 -0
  41. package/steering/vite-react/skills/plugin-guide/SKILL.md +3 -1
  42. package/steering/vite-react/skills/react-three-fiber/SKILL.md +4 -0
  43. package/steering/nestjs-react-fullstack/skills/client-add-aily-web-chat/SKILL.md +0 -139
  44. package/steering/nestjs-react-fullstack/skills/feishu/SKILL.md +0 -269
  45. package/steering/nestjs-react-fullstack/skills/feishu/references/approval.md +0 -214
  46. package/steering/nestjs-react-fullstack/skills/feishu/references/attendance.md +0 -163
  47. package/steering/nestjs-react-fullstack/skills/feishu/references/bitable.md +0 -311
  48. package/steering/nestjs-react-fullstack/skills/feishu/references/calendar.md +0 -190
  49. package/steering/nestjs-react-fullstack/skills/feishu/references/contacts.md +0 -160
  50. package/steering/nestjs-react-fullstack/skills/feishu/references/doc.md +0 -257
  51. package/steering/nestjs-react-fullstack/skills/feishu/references/drive.md +0 -104
  52. package/steering/nestjs-react-fullstack/skills/feishu/references/events.md +0 -199
  53. package/steering/nestjs-react-fullstack/skills/feishu/references/id-convert.md +0 -128
  54. package/steering/nestjs-react-fullstack/skills/feishu/references/messaging.md +0 -207
  55. package/steering/nestjs-react-fullstack/skills/feishu/references/oauth.md +0 -165
  56. package/steering/nestjs-react-fullstack/skills/feishu/references/perm.md +0 -91
  57. package/steering/nestjs-react-fullstack/skills/feishu/references/wiki.md +0 -165
  58. package/steering/nestjs-react-fullstack/skills_common/trigger-guide/references/trigger-lifecycle.md +0 -301
@@ -0,0 +1,60 @@
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('必要时可以使用 Colors 之外的颜色', body)
53
+ self.assertIn('不能形成与模板主色竞争的第二强调色', body)
54
+ self.assertIn('色相、明度和饱和度关系', body)
55
+ self.assertIn('高饱和、高对比、大面积或跨页重复', body)
56
+ self.assertIn('标题、关键数字、图表主序列、卡片底色或渐变', body)
57
+
58
+
59
+ if __name__ == '__main__':
60
+ unittest.main()
@@ -0,0 +1,63 @@
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
+ self.assertIn('沿用该页型已有的标题层级与局部 `css`', body)
57
+ self.assertIn('背景中已经可见的固定标题不再创建文本', body)
58
+ self.assertIn('没有 `subtitle` 槽就不新增副标题', body)
59
+ self.assertIn('区带自带 `margin: [左, 右]` 时用它的', body)
60
+
61
+
62
+ if __name__ == '__main__':
63
+ unittest.main()
@@ -0,0 +1,528 @@
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, apply_asset_decisions, build_layouts_md,
13
+ split_top_blocks)
14
+
15
+
16
+ def text_slot(role, box, text):
17
+ return {
18
+ 'role': role,
19
+ 'type': 'title' if role == 'title' else 'body',
20
+ 'box': box,
21
+ 'sz': 36,
22
+ 'txt': text,
23
+ 'css': 'font-size: 36px',
24
+ }
25
+
26
+
27
+ class FlowLayoutContractTest(unittest.TestCase):
28
+ def test_sample_page_keeps_every_text_slot_for_card_grouping(self):
29
+ slide_part = 'ppt/slides/slide2.xml'
30
+ layout_part = 'ppt/slideLayouts/slideLayout2.xml'
31
+ shapes = []
32
+ for index in range(8):
33
+ shapes.append({
34
+ 'part': slide_part,
35
+ 'layer': 'slide',
36
+ 'id': str(index + 1),
37
+ 'kind': 'sp',
38
+ 'name': 'Text',
39
+ 'ph': None,
40
+ 'box': {'x': 100 + (index % 3) * 560, 'y': 100 + index * 80,
41
+ 'w': 480, 'h': 60},
42
+ 'text': {
43
+ 'bodyPr': {},
44
+ 'lstStyle': {'lvl1pPr': {'sz_px': 32}},
45
+ 'paragraphs': [{'runs': [{'text': 'Text %d' % index}]}],
46
+ },
47
+ })
48
+ data = {
49
+ 'canvas': {'px': [1920, 1080]},
50
+ 'form_hint': {'form': 0},
51
+ 'slides': [{'part': slide_part, 'layout': layout_part, 'background': None}],
52
+ 'background_composites': {},
53
+ }
54
+
55
+ with tempfile.TemporaryDirectory() as output_dir:
56
+ os.makedirs(os.path.join(output_dir, 'ref'))
57
+ with open(os.path.join(output_dir, 'ref', 'shapes.json'), 'w',
58
+ encoding='utf-8') as stream:
59
+ import json
60
+ json.dump({'shapes': shapes}, stream)
61
+ archetypes, _, _ = draft_layouts(data, output_dir)
62
+
63
+ self.assertEqual(len(archetypes[0]['slots']), 8)
64
+
65
+ def test_layout_mode_selects_flow_without_leaking_draft_control(self):
66
+ draft = """names:
67
+ layout-1: 内容页
68
+ roles:
69
+ layout-1: content
70
+ layout_modes:
71
+ layout-1: flow
72
+ layouts:
73
+ layout-1:
74
+ flow:
75
+ top: 100
76
+ margin: [80, 80]
77
+ gap: 40
78
+ regions:
79
+ - kind: stack
80
+ gap: 20
81
+ items:
82
+ - {role: title, type: title}
83
+ - {role: body, type: body}
84
+ slots:
85
+ - {role: title, box: [80, 100, 1760, 80], type: title}
86
+ - {role: body, box: [80, 220, 1760, 400], type: body}
87
+ confidence: high
88
+ """
89
+
90
+ layouts_md = build_layouts_md(split_top_blocks(draft), (1920, 1080))
91
+
92
+ self.assertIn(' flow:', layouts_md)
93
+ self.assertNotIn(' slots:', layouts_md)
94
+ self.assertNotIn('layout_modes:', layouts_md)
95
+
96
+ slots_md = build_layouts_md(
97
+ split_top_blocks(draft.replace('layout-1: flow', 'layout-1: slots')),
98
+ (1920, 1080),
99
+ )
100
+
101
+ self.assertIn(' slots:', slots_md)
102
+ self.assertNotIn(' flow:', slots_md)
103
+ self.assertNotIn('layout_modes:', slots_md)
104
+
105
+ def test_layout_mode_defaults_to_slots_when_both_forms_exist(self):
106
+ draft = """names:
107
+ layout-1: 内容页
108
+ roles:
109
+ layout-1: content
110
+ layouts:
111
+ layout-1:
112
+ flow:
113
+ top: 100
114
+ margin: [80, 80]
115
+ gap: 40
116
+ regions:
117
+ - kind: stack
118
+ items:
119
+ - {role: title, type: title}
120
+ - {role: body, type: body}
121
+ slots:
122
+ - {role: title, box: [80, 100, 1760, 80], type: title}
123
+ - {role: body, box: [80, 220, 1760, 400], type: body}
124
+ confidence: high
125
+ """
126
+
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)
177
+
178
+ def test_layout_mode_rejects_unknown_value(self):
179
+ draft = """names:
180
+ layout-1: 内容页
181
+ roles:
182
+ layout-1: content
183
+ layout_modes:
184
+ layout-1: auto
185
+ layouts:
186
+ layout-1:
187
+ flow:
188
+ top: 100
189
+ margin: [80, 80]
190
+ gap: 40
191
+ regions:
192
+ - kind: stack
193
+ items:
194
+ - {role: title, type: title}
195
+ - {role: body, type: body}
196
+ slots:
197
+ - {role: title, box: [80, 100, 1760, 80], type: title}
198
+ - {role: body, box: [80, 220, 1760, 400], type: body}
199
+ confidence: high
200
+ """
201
+
202
+ with self.assertRaisesRegex(Fail, "layout-1 的 layout_modes 判断是 'auto'"):
203
+ build_layouts_md(split_top_blocks(draft), (1920, 1080))
204
+
205
+ def test_model_identified_footer_moves_to_positioned_free_region(self):
206
+ draft = """names:
207
+ layout-1: 内容页
208
+ roles:
209
+ layout-1: content
210
+ text_roles:
211
+ layout-1-text-1: title
212
+ layout-1-text-2: body
213
+ layout-1-text-3: footer
214
+ layout_modes:
215
+ layout-1: flow
216
+ layouts:
217
+ layout-1:
218
+ flow:
219
+ top: 100
220
+ margin: [80, 80]
221
+ gap: 40
222
+ regions:
223
+ - kind: stack
224
+ gap: 20
225
+ items:
226
+ # text-role: layout-1-text-1
227
+ - {role: body, type: body}
228
+ # text-role: layout-1-text-2
229
+ - {role: body, type: body}
230
+ # text-role: layout-1-text-3
231
+ - {role: body, type: body}
232
+ slots:
233
+ # text-role: layout-1-text-1
234
+ - {role: body, box: [80, 100, 1760, 80], type: body}
235
+ # text-role: layout-1-text-2
236
+ - {role: body, box: [80, 220, 1760, 400], type: body}
237
+ # text-role: layout-1-text-3
238
+ - {role: body, box: [80, 1000, 1760, 40], type: body}
239
+ confidence: high
240
+ """
241
+
242
+ layouts_md = build_layouts_md(split_top_blocks(draft), (1920, 1080))
243
+
244
+ self.assertIn(' - kind: free', layouts_md)
245
+ self.assertIn(
246
+ '- {role: footer, box: [80, 1000, 1760, 40], type: footer}',
247
+ layouts_md,
248
+ )
249
+ stack = layouts_md.split(' - kind: stack', 1)[1].split(
250
+ ' - kind: free', 1,
251
+ )[0]
252
+ self.assertNotIn('role: footer', stack)
253
+
254
+ def test_multicolumn_cards_emit_one_level_groups(self):
255
+ archetype = {
256
+ 'name': 'layout-1',
257
+ 'zh': '三列问题页',
258
+ 'role': 'content',
259
+ 'bg': None,
260
+ 'slots': [
261
+ text_slot('title', [120, 80, 1680, 80], '三大关键问题'),
262
+ text_slot('summary', [120, 200, 1680, 80], '本季度核心问题摘要'),
263
+ text_slot('card-title', [150, 360, 480, 60], '毛利承压'),
264
+ text_slot('body', [150, 440, 480, 180], '毛利率低于目标'),
265
+ text_slot('card-title', [720, 360, 480, 60], '收入延期'),
266
+ text_slot('body', [720, 440, 480, 180], '重点项目验收延后'),
267
+ text_slot('card-title', [1290, 360, 480, 60], '续约下滑'),
268
+ text_slot('body', [1290, 440, 480, 180], '中小客户流失增加'),
269
+ ],
270
+ 'decor': [
271
+ {
272
+ 'box': [120, 330, 540, 420],
273
+ 'geom': 'rect',
274
+ 'css': 'background: #FFFFFF; border: 1px solid #DDE7F2',
275
+ },
276
+ {
277
+ 'box': [690, 330, 540, 420],
278
+ 'geom': 'rect',
279
+ 'css': 'background: #FFFFFF; border: 1px solid #DDE7F2',
280
+ },
281
+ {
282
+ 'box': [1260, 330, 540, 420],
283
+ 'geom': 'rect',
284
+ 'css': 'background: #FFFFFF; border: 1px solid #DDE7F2',
285
+ },
286
+ {
287
+ 'box': [115, 660, 550, 95],
288
+ 'geom': 'rect',
289
+ 'css': 'background: #0A84FF',
290
+ },
291
+ {
292
+ 'box': [685, 660, 550, 95],
293
+ 'geom': 'rect',
294
+ 'css': 'background: #0A84FF',
295
+ },
296
+ {
297
+ 'box': [1255, 660, 550, 95],
298
+ 'geom': 'rect',
299
+ 'css': 'background: #0A84FF',
300
+ },
301
+ ],
302
+ 'pages': [2],
303
+ 'rep': 2,
304
+ 'pic_n': 0,
305
+ 'confidence': 'medium',
306
+ }
307
+ archetype['flow'] = draft_flow(archetype, {}, (1920, 1080))
308
+
309
+ with tempfile.TemporaryDirectory() as output_dir:
310
+ emit_layouts([archetype], output_dir)
311
+ with open(os.path.join(output_dir, 'layouts.yaml'), encoding='utf-8') as stream:
312
+ layouts_yaml = stream.read()
313
+
314
+ self.assertNotIn('\nlayout_modes:\n', layouts_yaml)
315
+ self.assertIn('默认保留 slots', layouts_yaml)
316
+ self.assertIn(' - kind: grid\n cols: 3', layouts_yaml)
317
+ self.assertEqual(layouts_yaml.count(' - role: group'), 3)
318
+ self.assertEqual(layouts_yaml.count(' items:'), 3)
319
+ self.assertEqual(layouts_yaml.count('role: container'), 3)
320
+
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
+ )
330
+ layouts_md = build_layouts_md(split_top_blocks(decided), (1920, 1080))
331
+
332
+ self.assertNotIn('layout_modes:', layouts_md)
333
+ self.assertNotIn(' slots:', layouts_md)
334
+ self.assertIn(' flow:', layouts_md)
335
+ self.assertEqual(layouts_md.count(' - role: group'), 3)
336
+ self.assertEqual(layouts_md.count(' items:'), 3)
337
+
338
+ with tempfile.TemporaryDirectory() as pack_dir:
339
+ with open(os.path.join(pack_dir, 'design.md'), 'w', encoding='utf-8') as stream:
340
+ stream.write('---\nversion: alpha\nlayouts: layouts.md\n---\n')
341
+ with open(os.path.join(pack_dir, 'layouts.md'), 'w', encoding='utf-8') as stream:
342
+ stream.write(layouts_md)
343
+ pack = Pack(pack_dir)
344
+
345
+ self.assertEqual(rule_v2_14(pack).level, 'PASS')
346
+ self.assertEqual(rule_v2_16(pack).level, 'PASS')
347
+
348
+ def test_title_and_single_card_row_gap_form_two_flow_regions(self):
349
+ archetype = {
350
+ 'name': 'layout-1',
351
+ 'zh': '标题加三列卡片',
352
+ 'role': 'content',
353
+ 'bg': None,
354
+ 'slots': [
355
+ text_slot('title', [228, 73, 1465, 68], '核心能力'),
356
+ ],
357
+ 'decor': [],
358
+ 'pages': [2],
359
+ 'rep': 2,
360
+ 'pic_n': 0,
361
+ 'confidence': 'high',
362
+ }
363
+ for left in (70, 671, 1270):
364
+ archetype['decor'].append({
365
+ 'box': [left, 212, 580, 660],
366
+ 'geom': 'rect',
367
+ 'css': 'background: rgba(255,255,255,0.7)',
368
+ })
369
+ for top in (270, 374, 498, 621, 797):
370
+ archetype['slots'].append(
371
+ text_slot('body', [left + 80, top, 405, 41], '卡片内容'),
372
+ )
373
+
374
+ flow = draft_flow(archetype, {}, (1920, 1080))
375
+
376
+ self.assertIsNotNone(flow)
377
+ self.assertEqual([region['kind'] for region in flow['regions']], ['stack', 'grid'])
378
+ self.assertEqual(flow['regions'][1]['cols'], 3)
379
+ self.assertEqual(
380
+ [item['role'] for item in flow['regions'][1]['items']],
381
+ ['group', 'group', 'group'],
382
+ )
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
+
477
+ def test_fixed_template_anchors_stay_in_a_positioned_free_region(self):
478
+ archetype = {
479
+ 'name': 'layout-1',
480
+ 'zh': '内容页',
481
+ 'role': 'content',
482
+ 'bg': None,
483
+ 'slots': [
484
+ text_slot('title', [120, 100, 1680, 80], '标题'),
485
+ text_slot('body', [120, 260, 1680, 280], '正文'),
486
+ {
487
+ 'role': 'logo',
488
+ 'type': 'pic',
489
+ 'box': [1740, 40, 100, 60],
490
+ 'sz': 0,
491
+ 'txt': '',
492
+ 'asset': 'logo-primary',
493
+ },
494
+ {
495
+ 'role': 'slide-number',
496
+ 'type': 'slide-number',
497
+ 'box': [1780, 1000, 60, 40],
498
+ 'sz': 24,
499
+ 'txt': '2',
500
+ 'css': 'font-size: 24px',
501
+ },
502
+ ],
503
+ 'decor': [],
504
+ 'pages': [2],
505
+ 'rep': 2,
506
+ 'pic_n': 1,
507
+ 'confidence': 'medium',
508
+ }
509
+ archetype['flow'] = draft_flow(archetype, {}, (1920, 1080))
510
+
511
+ with tempfile.TemporaryDirectory() as output_dir:
512
+ emit_layouts([archetype], output_dir)
513
+ with open(os.path.join(output_dir, 'layouts.yaml'), encoding='utf-8') as stream:
514
+ layouts_yaml = stream.read()
515
+
516
+ self.assertIn(' - kind: free', layouts_yaml)
517
+ self.assertIn(
518
+ '- {role: logo, type: pic, box: [1740, 40, 100, 60], asset: logo-primary}',
519
+ layouts_yaml,
520
+ )
521
+ self.assertIn(
522
+ '- {role: slide-number, type: slide-number, box: [1780, 1000, 60, 40]',
523
+ layouts_yaml,
524
+ )
525
+
526
+
527
+ if __name__ == '__main__':
528
+ unittest.main()