@lark-apaas/coding-steering 0.1.32-dev.4f80f68 → 0.1.32-dev.942e73f

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 (18) hide show
  1. package/package.json +1 -1
  2. package/steering/design-html/skills/pptx-style-extract/SKILL.md +27 -18
  3. package/steering/design-html/skills/pptx-style-extract/scripts/census.py +8 -2
  4. package/steering/design-html/skills/pptx-style-extract/scripts/draft.py +1152 -178
  5. package/steering/design-html/skills/pptx-style-extract/scripts/extract.py +229 -10
  6. package/steering/design-html/skills/pptx-style-extract/scripts/ooxml.py +18 -1
  7. package/steering/design-html/skills/pptx-style-extract/scripts/package.py +643 -40
  8. package/steering/design-html/skills/pptx-style-extract/scripts/parts.py +19 -3
  9. package/steering/design-html/skills/pptx-style-extract/scripts/render_pages.py +4 -2
  10. package/steering/design-html/skills/pptx-style-extract/scripts/test_asset_judgment_package.py +556 -0
  11. package/steering/design-html/skills/pptx-style-extract/scripts/test_background_composite.py +308 -1
  12. package/steering/design-html/skills/pptx-style-extract/scripts/test_design_consumer_contract.py +6 -0
  13. package/steering/design-html/skills/pptx-style-extract/scripts/test_flow_layout_contract.py +157 -7
  14. package/steering/design-html/skills/pptx-style-extract/scripts/test_layout_css.py +1519 -2
  15. package/steering/design-html/skills/pptx-style-extract/scripts/test_logo_scope.py +148 -0
  16. package/steering/design-html/skills/pptx-style-extract/scripts/test_text_role_contract.py +151 -4
  17. package/steering/design-html/skills/pptx-style-extract/scripts/verify_logo_scope.py +188 -0
  18. package/steering/design-html/skills/pptx-style-extract/v2-format-spec.md +1 -1
@@ -3,18 +3,33 @@
3
3
  import io
4
4
  import os
5
5
  import sys
6
+ import tempfile
6
7
  import unittest
8
+ from unittest import mock
9
+ from xml.etree import ElementTree as ET
7
10
 
8
11
  from PIL import Image
9
12
 
10
13
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
11
14
 
12
- from extract import _draw_picture # noqa: E402
15
+ import extract # noqa: E402
16
+ from extract import ( # noqa: E402
17
+ _draw_picture,
18
+ _is_full_canvas_fill_overlay,
19
+ compose_backgrounds,
20
+ export_media,
21
+ export_visible_picture_instances,
22
+ )
23
+ from census import image_census # noqa: E402
24
+ from ooxml import Units # noqa: E402
25
+ from parts import walk_tree # noqa: E402
26
+ from render_pages import background_css # noqa: E402
13
27
 
14
28
 
15
29
  class _Archive:
16
30
  def __init__(self, media, raw):
17
31
  self.names = {media}
32
+ self.media = [media]
18
33
  self.zip = self
19
34
  self.raw = raw
20
35
 
@@ -23,6 +38,35 @@ class _Archive:
23
38
  raise KeyError(media)
24
39
  return self.raw
25
40
 
41
+ def size_of(self, media):
42
+ if media not in self.names:
43
+ raise KeyError(media)
44
+ return len(self.raw)
45
+
46
+
47
+ class _CompositeArchive(_Archive):
48
+ def __init__(self, media, raw, slide):
49
+ super().__init__(media, raw)
50
+ self.layouts = []
51
+ self.slides = [slide]
52
+
53
+ @staticmethod
54
+ def xml(_part):
55
+ return {}
56
+
57
+
58
+ class _ShapeContext:
59
+ def __init__(self, media):
60
+ self.part = 'ppt/slides/slide1.xml'
61
+ self.layer = 'slide'
62
+ self.units = Units(1920, 1080)
63
+ self.clrmap = {}
64
+ self.clrscheme = {}
65
+ self.media = media
66
+
67
+ def media_of(self, _relationship_id):
68
+ return self.media
69
+
26
70
 
27
71
  def _png(pixels):
28
72
  image = Image.new('RGBA', (len(pixels), 1))
@@ -52,6 +96,269 @@ class BackgroundCompositeTest(unittest.TestCase):
52
96
  self.assertEqual(left[3], 255)
53
97
  self.assertEqual(right[3], 255)
54
98
 
99
+ def test_exports_a_cropped_rotated_visible_instance_from_a_near_blank_source(self):
100
+ media = 'ppt/media/source.png'
101
+ source = Image.new('RGBA', (1000, 1000))
102
+ for x in range(460, 540):
103
+ for y in range(460, 540):
104
+ source.putpixel((x, y), (15, 80, 160, 255))
105
+ raw = io.BytesIO()
106
+ source.save(raw, 'PNG')
107
+ package = _Archive(media, raw.getvalue())
108
+ shape = {
109
+ 'kind': 'pic',
110
+ 'media': media,
111
+ 'part': 'ppt/slides/slide2.xml',
112
+ 'box_unrotated': {'x': 100, 'y': 200, 'w': 20, 'h': 100},
113
+ 'box': {'x': 50, 'y': 240, 'w': 120, 'h': 20},
114
+ 'crop': {'l': 40, 't': 40, 'r': 40, 'b': 40},
115
+ 'rot': 90,
116
+ }
117
+
118
+ with tempfile.TemporaryDirectory() as outdir:
119
+ rows = export_visible_picture_instances(package, [shape], outdir, True)
120
+ self.assertEqual(len(rows), 1)
121
+ self.assertTrue(rows[0]['generated'])
122
+ self.assertEqual(rows[0]['rendered_from'], media)
123
+ self.assertTrue(shape['media'].startswith('generated/instance/'))
124
+
125
+ rendered = Image.open(os.path.join(outdir, rows[0]['out'])).convert('RGBA')
126
+ self.assertGreater(rendered.width, rendered.height)
127
+ self.assertGreater(sum(pixel[3] for pixel in rendered.getdata()) / rendered.width
128
+ / rendered.height, 13)
129
+
130
+ def test_instance_webp_writer_removes_partial_output_after_a_save_error(self):
131
+ canvas = Image.new('RGBA', (1, 1), (15, 80, 160, 255))
132
+
133
+ with tempfile.TemporaryDirectory() as outdir:
134
+ path = os.path.join(outdir, 'instance.webp')
135
+ with mock.patch.object(Image.Image, 'save', side_effect=OSError('webp unavailable')):
136
+ reason = extract._save_picture_instance_webp(canvas, path)
137
+
138
+ self.assertEqual('pillow-error: OSError: webp unavailable', reason)
139
+ self.assertFalse(os.path.exists(path))
140
+ self.assertFalse(os.path.exists(path + '.tmp'))
141
+
142
+ def test_instance_webp_failure_keeps_the_source_media_and_records_the_reason(self):
143
+ media = 'ppt/media/source.png'
144
+ package = _Archive(media, _png([(15, 80, 160, 255)]))
145
+ shape = {
146
+ 'kind': 'pic',
147
+ 'media': media,
148
+ 'part': 'ppt/slides/slide2.xml',
149
+ 'layer': 'slide',
150
+ 'box_unrotated': {'x': 0, 'y': 0, 'w': 100, 'h': 100},
151
+ 'box': {'x': 0, 'y': 0, 'w': 100, 'h': 100},
152
+ 'rot': 90,
153
+ }
154
+
155
+ with tempfile.TemporaryDirectory() as outdir:
156
+ blocked = {}
157
+ with mock.patch.object(
158
+ extract, '_save_picture_instance_webp',
159
+ return_value='pillow-error: OSError: webp unavailable'):
160
+ rows = export_visible_picture_instances(
161
+ package, [shape], outdir, True, blocked)
162
+ images, _ = image_census([shape], [], Units(1920, 1080))
163
+ media_rows = export_media(package, images, outdir, True)
164
+
165
+ self.assertEqual([], rows)
166
+ self.assertEqual(media, shape['media'])
167
+ self.assertTrue(images[0]['visible_instance_blocked'])
168
+ self.assertTrue(media_rows[0]['candidate'])
169
+ self.assertIn('visible_instance_fallback', media_rows[0]['reasons'])
170
+ self.assertEqual({
171
+ media: {
172
+ 'reason': 'pillow-error: OSError: webp unavailable',
173
+ 'attempted_specs': 1,
174
+ 'part_refs': ['ppt/slides/slide2.xml'],
175
+ },
176
+ }, blocked)
177
+
178
+ def test_alpha_modulations_combine_for_a_visible_instance(self):
179
+ media = 'ppt/media/source.png'
180
+ package = _Archive(media, _png([(20, 40, 60, 255)]))
181
+ picture_tree = ET.fromstring('''
182
+ <p:spTree xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
183
+ xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"
184
+ xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
185
+ <p:pic>
186
+ <p:nvPicPr><p:cNvPr id="1" name="half-alpha"/><p:cNvPicPr/><p:nvPr/></p:nvPicPr>
187
+ <p:blipFill>
188
+ <a:blip r:embed="rId1">
189
+ <a:alphaMod amt="50000"/>
190
+ <a:alphaModFix amt="50000"/>
191
+ </a:blip>
192
+ <a:stretch><a:fillRect/></a:stretch>
193
+ </p:blipFill>
194
+ <p:spPr>
195
+ <a:xfrm><a:off x="0" y="0"/><a:ext cx="100" cy="100"/></a:xfrm>
196
+ <a:prstGeom prst="rect"><a:avLst/></a:prstGeom>
197
+ </p:spPr>
198
+ </p:pic>
199
+ </p:spTree>
200
+ ''')
201
+ shapes = []
202
+ walk_tree(picture_tree, _ShapeContext(media), shapes)
203
+
204
+ self.assertEqual(shapes[0]['opacity'], 0.25)
205
+ with tempfile.TemporaryDirectory() as outdir:
206
+ rows = export_visible_picture_instances(package, shapes, outdir, True)
207
+ self.assertEqual(len(rows), 1)
208
+ rendered = Image.open(os.path.join(outdir, rows[0]['out'])).convert('RGBA')
209
+ self.assertAlmostEqual(rendered.getpixel((50, 50))[3], 64, delta=2)
210
+
211
+ def test_composites_full_canvas_gradient_and_solid_fill_overlays(self):
212
+ media = 'ppt/media/source.png'
213
+ slide = 'ppt/slides/slide1.xml'
214
+ package = _CompositeArchive(media, _png([(0, 0, 255, 255)]), slide)
215
+ picture = {
216
+ 'part': slide,
217
+ 'kind': 'pic',
218
+ 'media': media,
219
+ 'box_unrotated': {'x': 0, 'y': 0, 'w': 2, 'h': 1},
220
+ 'w_pct': 100,
221
+ 'h_pct': 100,
222
+ }
223
+ shape_base = {
224
+ 'geom': {'prst': 'rect'},
225
+ 'line': {'none': True},
226
+ }
227
+ gradient_overlay = {
228
+ **shape_base,
229
+ 'part': slide,
230
+ 'kind': 'sp',
231
+ 'box_unrotated': {'x': 0, 'y': 0, 'w': 2, 'h': 1},
232
+ 'w_pct': 100,
233
+ 'h_pct': 100,
234
+ 'fill': {
235
+ 'type': 'gradient',
236
+ 'stops': [
237
+ {'pos': 0, 'color': {'resolved': 'rgba(255,0,0,0.5)'}},
238
+ {'pos': 100, 'color': {'resolved': 'rgba(255,0,0,0.5)'}},
239
+ ],
240
+ 'angle_deg': 0,
241
+ },
242
+ }
243
+ solid_overlay = {
244
+ **shape_base,
245
+ 'part': slide,
246
+ 'kind': 'sp',
247
+ 'box_unrotated': {'x': 0, 'y': 0, 'w': 2, 'h': 1},
248
+ 'w_pct': 100,
249
+ 'h_pct': 100,
250
+ 'fill': {
251
+ 'type': 'solid',
252
+ 'color': {'resolved': 'rgba(0,255,0,0.5)'},
253
+ },
254
+ }
255
+ text_overlay = dict(solid_overlay, text={
256
+ 'paragraphs': [{'runs': [{'text': 'Do not flatten me'}]}],
257
+ })
258
+
259
+ self.assertTrue(_is_full_canvas_fill_overlay(gradient_overlay))
260
+ self.assertFalse(_is_full_canvas_fill_overlay(text_overlay))
261
+ self.assertFalse(_is_full_canvas_fill_overlay({
262
+ **gradient_overlay,
263
+ 'fill': {**gradient_overlay['fill'], 'path': 'circle'},
264
+ }))
265
+ with tempfile.TemporaryDirectory() as outdir:
266
+ part_map, rows, _ = compose_backgrounds(
267
+ package,
268
+ {'layout_of_slide': {}, 'master_of_layout': {}},
269
+ [picture, gradient_overlay, solid_overlay, text_overlay],
270
+ {},
271
+ Units(2, 1),
272
+ outdir,
273
+ True,
274
+ )
275
+
276
+ self.assertEqual(set(part_map), {slide})
277
+ self.assertEqual(len(rows), 1)
278
+ self.assertEqual(
279
+ [layer['kind'] for layer in rows[0]['composited_from']],
280
+ ['picture', 'fill_overlay', 'fill_overlay'],
281
+ )
282
+ rendered = Image.open(os.path.join(outdir, rows[0]['out'])).convert('RGB')
283
+ red, green, blue = rendered.getpixel((0, 0))
284
+ self.assertGreater(red, 50)
285
+ self.assertGreater(green, 100)
286
+ self.assertGreater(blue, 50)
287
+
288
+ def test_preserves_fill_and_picture_draw_order(self):
289
+ media = 'ppt/media/source.png'
290
+ slide = 'ppt/slides/slide1.xml'
291
+ package = _CompositeArchive(media, _png([(0, 0, 255, 255)]), slide)
292
+ overlay = {
293
+ 'part': slide,
294
+ 'kind': 'sp',
295
+ 'geom': {'prst': 'rect'},
296
+ 'line': {'none': True},
297
+ 'box_unrotated': {'x': 0, 'y': 0, 'w': 2, 'h': 1},
298
+ 'w_pct': 100,
299
+ 'h_pct': 100,
300
+ 'fill': {'type': 'solid', 'color': {'resolved': 'rgba(255,0,0,0.5)'}},
301
+ }
302
+ picture = {
303
+ 'part': slide,
304
+ 'kind': 'pic',
305
+ 'media': media,
306
+ 'box_unrotated': {'x': 0, 'y': 0, 'w': 2, 'h': 1},
307
+ 'w_pct': 100,
308
+ 'h_pct': 100,
309
+ }
310
+
311
+ with tempfile.TemporaryDirectory() as outdir:
312
+ _, rows, _ = compose_backgrounds(
313
+ package,
314
+ {'layout_of_slide': {}, 'master_of_layout': {}},
315
+ [overlay, picture],
316
+ {},
317
+ Units(2, 1),
318
+ outdir,
319
+ True,
320
+ )
321
+
322
+ rendered = Image.open(os.path.join(outdir, rows[0]['out'])).convert('RGB')
323
+ self.assertEqual(rendered.getpixel((0, 0)), (0, 0, 255))
324
+
325
+ def test_path_gradient_background_is_not_linearized_for_compositing(self):
326
+ media = 'ppt/media/source.png'
327
+ slide = 'ppt/slides/slide1.xml'
328
+ package = _CompositeArchive(media, _png([(0, 0, 255, 255)]), slide)
329
+ picture = {
330
+ 'part': slide,
331
+ 'kind': 'pic',
332
+ 'media': media,
333
+ 'box_unrotated': {'x': 0, 'y': 0, 'w': 2, 'h': 1},
334
+ 'w_pct': 100,
335
+ 'h_pct': 100,
336
+ 'rot': 90,
337
+ }
338
+ background = {
339
+ 'type': 'gradient',
340
+ 'path': 'circle',
341
+ 'stops': [
342
+ {'pos': 0, 'color': {'resolved': '#FF0000'}},
343
+ {'pos': 100, 'color': {'resolved': '#00FF00'}},
344
+ ],
345
+ }
346
+
347
+ self.assertIsNone(background_css(background))
348
+ with tempfile.TemporaryDirectory() as outdir:
349
+ _, rows, _ = compose_backgrounds(
350
+ package,
351
+ {'layout_of_slide': {}, 'master_of_layout': {}},
352
+ [picture],
353
+ {slide: background},
354
+ Units(2, 1),
355
+ outdir,
356
+ True,
357
+ )
358
+
359
+ rendered = Image.open(os.path.join(outdir, rows[0]['out'])).convert('RGB')
360
+ self.assertEqual(rendered.getpixel((0, 0)), (0, 0, 255))
361
+
55
362
 
56
363
  if __name__ == '__main__':
57
364
  unittest.main()
@@ -56,6 +56,12 @@ 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)
60
+ self.assertIn('必须完整阅读本 `design.md` 和 `layouts.md`', body)
61
+ self.assertIn('确认全部页型后再开始搭页', body)
62
+ self.assertIn('不能只看摘要、前几个页型', body)
63
+ self.assertIn('data-pptx-layout="<页型名>"', body)
64
+ self.assertIn('交付前据此核验 logo 只出现在该页型的资产槽中', body)
59
65
 
60
66
 
61
67
  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, split_top_blocks
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 test_layout_mode_is_required_when_both_forms_exist(self):
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
- with self.assertRaisesRegex(Fail, 'layout-1 缺 layout_modes 判断'):
127
- build_layouts_md(split_top_blocks(draft), (1920, 1080))
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.assertIn('layout_modes:', layouts_yaml)
266
- self.assertIn(' layout-1: TODO布局模式', layouts_yaml)
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
- decided = layouts_yaml.replace('TODO布局模式', 'flow')
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',