@lark-apaas/coding-steering 0.1.32 → 0.1.33-beta.0

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 (20) hide show
  1. package/package.json +1 -1
  2. package/steering/design-html/skills/pptx-style-extract/SKILL.md +58 -22
  3. package/steering/design-html/skills/pptx-style-extract/font-fallback.yaml +3 -3
  4. package/steering/design-html/skills/pptx-style-extract/scripts/census.py +18 -12
  5. package/steering/design-html/skills/pptx-style-extract/scripts/check_v2.py +153 -8
  6. package/steering/design-html/skills/pptx-style-extract/scripts/draft.py +1768 -241
  7. package/steering/design-html/skills/pptx-style-extract/scripts/extract.py +325 -22
  8. package/steering/design-html/skills/pptx-style-extract/scripts/ooxml.py +1 -1
  9. package/steering/design-html/skills/pptx-style-extract/scripts/package.py +379 -156
  10. package/steering/design-html/skills/pptx-style-extract/scripts/parts.py +6 -3
  11. package/steering/design-html/skills/pptx-style-extract/scripts/query.py +4 -9
  12. package/steering/design-html/skills/pptx-style-extract/scripts/render_pages.py +16 -10
  13. package/steering/design-html/skills/pptx-style-extract/scripts/test_background_composite.py +57 -0
  14. package/steering/design-html/skills/pptx-style-extract/scripts/test_color_contract.py +60 -0
  15. package/steering/design-html/skills/pptx-style-extract/scripts/test_design_consumer_contract.py +62 -0
  16. package/steering/design-html/skills/pptx-style-extract/scripts/test_flow_layout_contract.py +378 -0
  17. package/steering/design-html/skills/pptx-style-extract/scripts/test_layout_css.py +98 -0
  18. package/steering/design-html/skills/pptx-style-extract/scripts/test_rounded_contract.py +112 -0
  19. package/steering/design-html/skills/pptx-style-extract/scripts/test_text_role_contract.py +168 -0
  20. package/steering/design-html/skills/pptx-style-extract/v2-format-spec.md +27 -15
@@ -39,15 +39,15 @@ class Package:
39
39
  self.masters = sorted((n for n in self.names if MASTER_RE.match(n)), key=_num)
40
40
  self.themes, self.theme_discovery = self._find_themes(ct)
41
41
  # zip directory entries ('ppt/media/') must not be counted as assets —
42
- # 03 recon counted one, which is why its media totals run one high.
42
+ # they inflate media totals by one.
43
43
  self.media = sorted(n for n in self.names
44
44
  if n.startswith('ppt/media/') and not n.endswith('/'))
45
45
 
46
46
  def _find_themes(self, ct):
47
47
  """Theme parts come from [Content_Types].xml, not from a path pattern.
48
48
 
49
- Compressors relocate them — 飞书压缩版 puts them at
50
- ppt/slideMasters/theme/themeN.xml — and a `ppt/theme/` regex then finds
49
+ Compressors relocate them — some rewrite them under
50
+ ppt/slideMasters/theme/ — and a `ppt/theme/` regex then finds
51
51
  nothing to bind the masters to, so every schemeClr resolves to
52
52
  UNRESOLVED:no-scheme. The path regex stays as the fallback for packages
53
53
  whose content types are unreadable.
@@ -393,6 +393,9 @@ def walk_tree(el, ctx, out, path=(), xf=(1.0, 1.0, 0.0, 0.0), depth=0):
393
393
  blip = sp.find('p:blipFill/a:blip', NS)
394
394
  if blip is not None:
395
395
  rec['media'] = ctx.media_of(blip.get(R_EMBED)) or ctx.media_of(blip.get(R_LINK))
396
+ alpha = blip.find('a:alphaModFix', NS)
397
+ if alpha is not None and alpha.get('amt') is not None:
398
+ rec['opacity'] = round(int(alpha.get('amt')) / 100000.0, 6)
396
399
  svg = blip.find('a:extLst//asvg:svgBlip', NS)
397
400
  if svg is not None:
398
401
  rec['media_svg'] = ctx.media_of(svg.get(R_EMBED))
@@ -244,14 +244,10 @@ def _recipe_css(fill, line, radii, effects):
244
244
  dash = (line or {}).get('dash')
245
245
  style = 'dashed' if dash and 'dash' in dash else 'solid'
246
246
  out.append('border: %dpx %s %s' % (w, style, lc))
247
- if radii:
247
+ if radii and all(r >= 1 for r in radii):
248
248
  lo, hi = min(radii), max(radii)
249
249
  if abs(hi - lo) <= 0.5:
250
250
  out.append('border-radius: %gpx' % round(lo, 1))
251
- else:
252
- out.append('border-radius: %gpx\x00/* 源内 %g~%gpx 共 %d 档,归一档位由 L11 定 */'
253
- % (round(sum(radii) / len(radii), 1), round(lo, 1), round(hi, 1),
254
- len(set(round(r, 1) for r in radii))))
255
251
  for e in effects or []:
256
252
  if e.get('type') == 'outerShdw':
257
253
  col = _css_color(e.get('color')) or 'rgba(0,0,0,0.25)'
@@ -266,8 +262,8 @@ def _recipe_css(fill, line, radii, effects):
266
262
 
267
263
  def _sig(fill, line, effects):
268
264
  """分组键 = 填充 + 描边 + 效果。**不含圆角**——OOXML 圆角是 min(w,h) 的百分比,
269
- 同一配方在不同尺寸的卡上绝对 px 必然不同(feishu 三张 glassCard 实测 10.8/12.8/19.5),
270
- 把它计入键会把一个配方拆成三组;归一到哪一档是 L11 的判断,脚本只报区间。"""
265
+ 同一配方在不同尺寸的卡上绝对 px 必然不同,把它计入键会把一个配方拆成多组;
266
+ 只有组内每个形状都明确共享同一绝对半径时才输出组级圆角,否则留给逐形状 CSS。"""
271
267
  f = 'none'
272
268
  if isinstance(fill, dict):
273
269
  if fill.get('type') == 'solid':
@@ -305,8 +301,7 @@ def cmd_recipes(a, outdir):
305
301
  g = groups.setdefault(k, {'n': 0, 'parts': Counter(), 'sizes': [], 'radii': [],
306
302
  'fill': fill, 'line': line, 'fx': fx})
307
303
  g['n'] += 1
308
- if radius:
309
- g['radii'].append(radius)
304
+ g['radii'].append(radius or 0)
310
305
  g['parts'][short(s['part'])] += 1
311
306
  b = s.get('box') or {}
312
307
  if b.get('w'):
@@ -279,18 +279,19 @@ def render_shape(shape, media_url, stats):
279
279
 
280
280
  HEAD = """<style>
281
281
  body{margin:0;background:#2b2b2b;font-family:%s}
282
- .page{position:relative;width:1920px;height:1080px;overflow:hidden}
282
+ .page{position:relative;width:%dpx;height:%dpx;overflow:hidden}
283
283
  .sp{position:absolute;box-sizing:border-box}
284
284
  .sp p{margin:0}
285
285
  .ph{position:absolute;left:6px;top:4px;font:16px/1.2 monospace;color:#ff2b88;background:#fff9;padding:1px 4px}
286
286
  </style>
287
- """ % CJK_STACK
287
+ """
288
288
 
289
+ # 缩略框跟着画布比例走,缩放比也一起算——写死 16:9 会把非 16:9 模板的页面裁掉一截
289
290
  INDEX_EXTRA = """<style>
290
- .wrap{width:960px;margin:0 auto;padding:16px 0}
291
+ .wrap{width:%dpx;margin:0 auto;padding:16px 0}
291
292
  .lbl{color:#eee;font:13px/1.6 monospace;margin:14px 0 4px}
292
- .box{width:960px;height:540px;overflow:hidden;margin-bottom:6px}
293
- .box .page{transform:scale(.5);transform-origin:top left}
293
+ .box{width:%dpx;height:%dpx;overflow:hidden;margin-bottom:6px}
294
+ .box .page{transform:scale(%g);transform-origin:top left}
294
295
  </style>
295
296
  """
296
297
 
@@ -508,7 +509,8 @@ def render_pages_png(pages, outdir, data, scale=0.5):
508
509
  for m in data.get('media') or [] if m.get('exported') and m.get('out')}
509
510
  png_dir = os.path.join(outdir, 'ref', 'rebuild', 'png')
510
511
  os.makedirs(png_dir, exist_ok=True)
511
- W, H = int(1920 * scale), int(1080 * scale)
512
+ cpx = ((data.get('canvas') or {}).get('px') or [1920, 1080])
513
+ W, H = int(cpx[0] * scale), int(cpx[1] * scale)
512
514
  for kind, n, label, layers, default_color, page_bg in pages:
513
515
  canvas = Image.new('RGBA', (W, H), _rgba(page_bg) or (136, 136, 136, 255))
514
516
  g = _grad_stops(page_bg or '')
@@ -642,15 +644,19 @@ def main():
642
644
  tx1_of_master.get(master, '#000000'),
643
645
  layout_bg.get(part, '#888')))
644
646
 
645
- if only is not None:
646
- pages = [p for p in pages if p[0] != 'slide' or p[1] in only]
647
+ if only is not None: # 对样张和版式一律生效(form=3 的代表页是版式,不是样张)
648
+ pages = [p for p in pages if p[1] in only]
647
649
 
648
650
  stats = {'shapes': 0, 'text': 0, 'img_ok': 0, 'img_missing': 0, 'no_box': 0}
649
- index = [HEAD, INDEX_EXTRA, '<div class="wrap">']
651
+ cw, ch = ((data.get('canvas') or {}).get('px') or [1920, 1080])[:2]
652
+ head = HEAD % (CJK_STACK, cw, ch) # 视口跟画布走,非 16:9 模板不能按 1920x1080 裁
653
+ thumb_scale = 960.0 / cw
654
+ index = [head, INDEX_EXTRA % (960, 960, round(ch * thumb_scale), thumb_scale),
655
+ '<div class="wrap">']
650
656
  for kind, n, label, layers, default_color, page_bg in (() if args.no_html else pages):
651
657
  html = page_html(layers, default_color, page_bg, media_url, stats)
652
658
  with open(os.path.join(rebuild, '%s-%d.html' % (kind, n)), 'w', encoding='utf-8') as f:
653
- f.write(HEAD + html)
659
+ f.write(head + html)
654
660
  index += ['<div class="lbl">%s</div>' % esc(label),
655
661
  '<div class="box">%s</div>' % html]
656
662
  index.append('</div>')
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env python3
2
+ """Focused regression tests for deterministic picture compositing."""
3
+ import io
4
+ import os
5
+ import sys
6
+ import unittest
7
+
8
+ from PIL import Image
9
+
10
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
11
+
12
+ from extract import _draw_picture # noqa: E402
13
+
14
+
15
+ class _Archive:
16
+ def __init__(self, media, raw):
17
+ self.names = {media}
18
+ self.zip = self
19
+ self.raw = raw
20
+
21
+ def read(self, media):
22
+ if media not in self.names:
23
+ raise KeyError(media)
24
+ return self.raw
25
+
26
+
27
+ def _png(pixels):
28
+ image = Image.new('RGBA', (len(pixels), 1))
29
+ image.putdata(pixels)
30
+ output = io.BytesIO()
31
+ image.save(output, 'PNG')
32
+ return output.getvalue()
33
+
34
+
35
+ class BackgroundCompositeTest(unittest.TestCase):
36
+ def test_applies_flip_and_opacity_once(self):
37
+ media = 'ppt/media/source.png'
38
+ package = _Archive(media, _png([(255, 0, 0, 255), (0, 0, 255, 255)]))
39
+ canvas = Image.new('RGBA', (2, 1), (255, 255, 255, 255))
40
+
41
+ drawn = _draw_picture(canvas, {
42
+ 'media': media,
43
+ 'box_unrotated': {'x': 0, 'y': 0, 'w': 2, 'h': 1},
44
+ 'flipH': True,
45
+ 'opacity': 0.5,
46
+ }, package)
47
+
48
+ self.assertTrue(drawn)
49
+ left, right = canvas.getpixel((0, 0)), canvas.getpixel((1, 0))
50
+ self.assertEqual(left[:3], (128, 128, 255))
51
+ self.assertEqual(right[:3], (255, 128, 128))
52
+ self.assertEqual(left[3], 255)
53
+ self.assertEqual(right[3], 255)
54
+
55
+
56
+ if __name__ == '__main__':
57
+ unittest.main()
@@ -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,62 @@
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
+
60
+
61
+ if __name__ == '__main__':
62
+ unittest.main()
@@ -0,0 +1,378 @@
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_fixed_template_anchors_stay_in_a_positioned_free_region(self):
328
+ archetype = {
329
+ 'name': 'layout-1',
330
+ 'zh': '内容页',
331
+ 'role': 'content',
332
+ 'bg': None,
333
+ 'slots': [
334
+ text_slot('title', [120, 100, 1680, 80], '标题'),
335
+ text_slot('body', [120, 260, 1680, 280], '正文'),
336
+ {
337
+ 'role': 'logo',
338
+ 'type': 'pic',
339
+ 'box': [1740, 40, 100, 60],
340
+ 'sz': 0,
341
+ 'txt': '',
342
+ 'asset': 'logo-primary',
343
+ },
344
+ {
345
+ 'role': 'slide-number',
346
+ 'type': 'slide-number',
347
+ 'box': [1780, 1000, 60, 40],
348
+ 'sz': 24,
349
+ 'txt': '2',
350
+ 'css': 'font-size: 24px',
351
+ },
352
+ ],
353
+ 'decor': [],
354
+ 'pages': [2],
355
+ 'rep': 2,
356
+ 'pic_n': 1,
357
+ 'confidence': 'medium',
358
+ }
359
+ archetype['flow'] = draft_flow(archetype, {}, (1920, 1080))
360
+
361
+ with tempfile.TemporaryDirectory() as output_dir:
362
+ emit_layouts([archetype], output_dir)
363
+ with open(os.path.join(output_dir, 'layouts.yaml'), encoding='utf-8') as stream:
364
+ layouts_yaml = stream.read()
365
+
366
+ self.assertIn(' - kind: free', layouts_yaml)
367
+ self.assertIn(
368
+ '- {role: logo, type: pic, box: [1740, 40, 100, 60], asset: logo-primary}',
369
+ layouts_yaml,
370
+ )
371
+ self.assertIn(
372
+ '- {role: slide-number, type: slide-number, box: [1780, 1000, 60, 40]',
373
+ layouts_yaml,
374
+ )
375
+
376
+
377
+ if __name__ == '__main__':
378
+ unittest.main()