@lark-apaas/coding-steering 0.1.18-dev.e6a2787 → 0.1.18-dev.ee5404f

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.
@@ -98,7 +98,8 @@ L 层判断单 schema(<l-out-dir> 四个文件,这段就是填写说明书
98
98
  role: cover
99
99
  background: bg-cover
100
100
  slots:
101
- - {role: title, box: [<x>, <y>, <w>, <h>], type: title}
101
+ - {role: title, box: [<x>, <y>, <w>, <h>], type: title,
102
+ css: "<由源模板转译出的 CSS 声明串>"}
102
103
  confidence: high
103
104
 
104
105
  可选 `body:` 块标量 —— 追加到 layouts.md frontmatter 之后作为说明正文。
@@ -507,7 +508,7 @@ def render_assets_block(consumer):
507
508
 
508
509
  # ------------------------------------------------- 数值可追溯机检(抄错即 FAIL)
509
510
  HEX_RE = re.compile(r'#([0-9A-Fa-f]{6})\b')
510
- FONTSIZE_RE = re.compile(r'\bfontSize:\s*([\d.]+)px')
511
+ FONTSIZE_RE = re.compile(r'\b(?:fontSize|font-size):\s*([\d.]+)px')
511
512
  BOX_RE = re.compile(r'\bbox:\s*\[\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*,'
512
513
  r'\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*\]')
513
514
  KEYLINE_RE = re.compile(r'^(\s*)-?\s*([A-Za-z_][\w-]*):\s*(.*)$')
@@ -960,8 +961,8 @@ def build_layouts_md(layouts_blocks, canvas):
960
961
  raise Fail('layouts.yaml 不要写 canvas —— 脚本从 extract.json 取')
961
962
  if 'layouts' not in blocks:
962
963
  raise Fail('layouts.yaml 缺顶层键 `layouts:`')
963
- # `names:` / `bg_rules:` 是给 L 层集中填判断的两块扁平区——在这里并回各
964
- # archetype,本身不进产物。让 L 层只改扁平键值,别去动 layouts 里的
964
+ # `names:` / `roles:` / `text_roles:` / `bg_rules:` 是给 L 层集中填判断的
965
+ # 扁平区——在这里并回各 archetype,本身不进产物。让 L 层只改扁平键值,别去动 layouts 里的
965
966
  # slots/confidence 结构(嵌套结构手改极易破坏缩进,进而静默改变语义)。
966
967
  names, roles = {}, {}
967
968
  for key, sink in (('names', names), ('roles', roles)):
@@ -969,6 +970,17 @@ def build_layouts_md(layouts_blocks, canvas):
969
970
  m = re.match(r'^\s{2}([\w-]+):\s*(.+?)\s*$', line)
970
971
  if m:
971
972
  sink[m.group(1)] = unquote(m.group(2))
973
+ text_roles = {}
974
+ allowed_text_roles = {'title', 'subtitle', 'header', 'footer', 'body'}
975
+ for line in blocks.get('text_roles', ('', []))[1]:
976
+ m = re.match(r'^\s{2}([\w-]+):\s*([A-Za-z-]+)(?:\s+#.*)?$', line)
977
+ if not m:
978
+ continue
979
+ role_id, role = m.groups()
980
+ if role not in allowed_text_roles:
981
+ raise Fail('text_roles.%s 取值 %s 非法;应为 %s'
982
+ % (role_id, role, '|'.join(sorted(allowed_text_roles))))
983
+ text_roles[role_id] = role
972
984
  bg_rules, cur = {}, None
973
985
  for line in blocks.get('bg_rules', ('', []))[1]:
974
986
  # 键后面允许行内注释(草案会标「用它的页型:…」)
@@ -991,11 +1003,29 @@ def build_layouts_md(layouts_blocks, canvas):
991
1003
  out.append(' %s:' % bg)
992
1004
  out += lines
993
1005
  out.append('layouts:')
1006
+ pending_text_role = None
1007
+ used_text_roles = set()
994
1008
  for line in blocks['layouts'][1]:
995
1009
  # 判断单里的结构事实(栅格、间距序列、样张字数、命中配方)是给 L 层判断用的,
996
1010
  # 不进产物——消费端要的是结论,不是推导过程。
997
1011
  if line.lstrip().startswith('#'):
1012
+ marker = re.match(r'^\s*#\s*text-role:\s*([\w-]+)\s*$', line)
1013
+ if marker:
1014
+ pending_text_role = marker.group(1)
998
1015
  continue
1016
+ if pending_text_role:
1017
+ if pending_text_role not in text_roles:
1018
+ raise Fail('text_roles 缺少 %s 的判断' % pending_text_role)
1019
+ role = text_roles[pending_text_role]
1020
+ slot_type = role if role in ('title', 'subtitle', 'footer') else 'body'
1021
+ line, role_n = re.subn(r'(\{\s*role:\s*)[\w-]+',
1022
+ r'\g<1>%s' % role, line, count=1)
1023
+ line, type_n = re.subn(r'(\btype:\s*)[\w-]+',
1024
+ r'\g<1>%s' % slot_type, line, count=1)
1025
+ if role_n != 1 or type_n != 1:
1026
+ raise Fail('text_roles.%s 没有命中一个文本槽' % pending_text_role)
1027
+ used_text_roles.add(pending_text_role)
1028
+ pending_text_role = None
999
1029
  out.append(line)
1000
1030
  m = re.match(r'^ ([\w-]+):\s*$', line)
1001
1031
  if m and m.group(1) in names:
@@ -1006,6 +1036,10 @@ def build_layouts_md(layouts_blocks, canvas):
1006
1036
  raise Fail('names 里这些页型在 layouts 下找不到:%s' % ', '.join(sorted(names)))
1007
1037
  if roles:
1008
1038
  raise Fail('roles 里这些页型在 layouts 下找不到:%s' % ', '.join(sorted(roles)))
1039
+ unused_text_roles = set(text_roles) - used_text_roles
1040
+ if unused_text_roles:
1041
+ raise Fail('text_roles 里这些判断没有命中文本槽:%s'
1042
+ % ', '.join(sorted(unused_text_roles)))
1009
1043
  missing_role = [k for k in re.findall(r'^ ([\w-]+):\s*$', '\n'.join(blocks['layouts'][1]), re.M)
1010
1044
  if not re.search(r'^ %s:\s*$(?:\n(?! \S).*)*?\n role:' % re.escape(k),
1011
1045
  '\n'.join(out), re.M)]
@@ -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)'
@@ -267,7 +263,7 @@ def _recipe_css(fill, line, radii, effects):
267
263
  def _sig(fill, line, effects):
268
264
  """分组键 = 填充 + 描边 + 效果。**不含圆角**——OOXML 圆角是 min(w,h) 的百分比,
269
265
  同一配方在不同尺寸的卡上绝对 px 必然不同,把它计入键会把一个配方拆成多组;
270
- 归一到哪一档是判断,脚本只报区间。"""
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'):
@@ -0,0 +1,58 @@
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('不得自行新增第二强调色', body)
53
+ self.assertIn('高饱和、高对比、大面积或跨页重复', body)
54
+ self.assertIn('标题、关键数字、图表主序列、卡片底色或渐变', body)
55
+
56
+
57
+ if __name__ == '__main__':
58
+ 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,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()
@@ -0,0 +1,168 @@
1
+ #!/usr/bin/env python3
2
+ """Regression tests for inherited layout text and model-decided text roles."""
3
+ import json
4
+ import os
5
+ import sys
6
+ import tempfile
7
+ import unittest
8
+
9
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
10
+
11
+ from draft import draft_layouts, emit_layouts, inherited_text_shapes
12
+ from package import build_layouts_md, split_top_blocks
13
+
14
+
15
+ def text_shape(part, layer, shape_id, box, text, placeholder):
16
+ return {
17
+ 'part': part,
18
+ 'layer': layer,
19
+ 'id': shape_id,
20
+ 'kind': 'sp',
21
+ 'name': 'Text Placeholder',
22
+ 'ph': placeholder,
23
+ 'box': box,
24
+ 'text': {
25
+ 'bodyPr': {},
26
+ 'lstStyle': {
27
+ 'lvl1pPr': {
28
+ 'sz_px': 48,
29
+ 'weight': 600,
30
+ 'color': {'resolved': '#C41230'},
31
+ },
32
+ },
33
+ 'paragraphs': [
34
+ {
35
+ 'runs': [{'text': text}],
36
+ },
37
+ ] if text else [],
38
+ },
39
+ }
40
+
41
+
42
+ class TextRoleContractTest(unittest.TestCase):
43
+ def test_empty_non_placeholder_layout_shape_is_not_a_text_slot(self):
44
+ layout_part = 'ppt/slideLayouts/slideLayout2.xml'
45
+ decorative_shape = text_shape(
46
+ layout_part,
47
+ 'layout',
48
+ '9',
49
+ {'x': 0, 'y': 0, 'w': 1920, 'h': 24},
50
+ '',
51
+ None,
52
+ )
53
+ decorative_shape['name'] = 'Decorative bar'
54
+
55
+ self.assertEqual(inherited_text_shapes([decorative_shape], []), [])
56
+
57
+ def test_empty_slide_inherits_text_slot_and_css_from_its_layout(self):
58
+ layout_part = 'ppt/slideLayouts/slideLayout2.xml'
59
+ slide_part = 'ppt/slides/slide1.xml'
60
+ shapes = [
61
+ text_shape(
62
+ layout_part,
63
+ 'layout',
64
+ '10',
65
+ {'x': 120, 'y': 80, 'w': 840, 'h': 120},
66
+ 'Example heading',
67
+ {'type': 'body', 'idx': '10'},
68
+ ),
69
+ text_shape(
70
+ slide_part,
71
+ 'slide',
72
+ '2',
73
+ None,
74
+ '',
75
+ {'type': 'body', 'idx': '10'},
76
+ ),
77
+ ]
78
+ data = {
79
+ 'canvas': {'px': [1920, 1080]},
80
+ 'form_hint': {'form': 0},
81
+ 'slides': [
82
+ {
83
+ 'part': slide_part,
84
+ 'layout': layout_part,
85
+ 'background': None,
86
+ },
87
+ ],
88
+ 'background_composites': {},
89
+ }
90
+
91
+ with tempfile.TemporaryDirectory() as output_dir:
92
+ os.makedirs(os.path.join(output_dir, 'ref'))
93
+ with open(
94
+ os.path.join(output_dir, 'ref', 'shapes.json'),
95
+ 'w',
96
+ encoding='utf-8',
97
+ ) as stream:
98
+ json.dump({'shapes': shapes}, stream)
99
+ archetypes, _, _ = draft_layouts(data, output_dir)
100
+
101
+ self.assertEqual(len(archetypes), 1)
102
+ self.assertEqual(len(archetypes[0]['slots']), 1)
103
+ slot = archetypes[0]['slots'][0]
104
+ self.assertEqual(slot['box'], [120, 80, 840, 120])
105
+ self.assertEqual(slot['txt'], 'Example heading')
106
+ self.assertEqual(slot['role'], 'body')
107
+ self.assertEqual(slot['type'], 'body')
108
+ self.assertTrue(slot['_needs_role'])
109
+ self.assertIn('font-size: 48px', slot['css'])
110
+ self.assertIn('font-weight: 600', slot['css'])
111
+ self.assertIn('color: #C41230', slot['css'])
112
+
113
+ def test_text_role_judgement_changes_semantics_without_dropping_slot(self):
114
+ archetype = {
115
+ 'name': 'layout-1',
116
+ 'zh': None,
117
+ 'role': 'content',
118
+ 'bg': None,
119
+ 'slots': [
120
+ {
121
+ 'role': 'body',
122
+ 'type': 'body',
123
+ 'box': [120, 80, 840, 120],
124
+ 'sz': 48,
125
+ 'txt': 'Example heading',
126
+ 'css': 'font-size: 48px; color: #C41230',
127
+ '_needs_role': True,
128
+ '_source_layer': 'layout',
129
+ '_placeholder': 'body/10',
130
+ },
131
+ ],
132
+ 'decor': [],
133
+ 'pages': [1],
134
+ 'rep': 1,
135
+ 'pic_n': 0,
136
+ 'confidence': 'low',
137
+ }
138
+
139
+ with tempfile.TemporaryDirectory() as output_dir:
140
+ emit_layouts([archetype], output_dir)
141
+ path = os.path.join(output_dir, 'layouts.yaml')
142
+ with open(path, encoding='utf-8') as stream:
143
+ draft = stream.read()
144
+
145
+ self.assertIn('text_roles:', draft)
146
+ self.assertIn(
147
+ 'layout-1-text-1: TODO文本角色',
148
+ draft,
149
+ )
150
+ self.assertEqual(draft.count('box: [120, 80, 840, 120]'), 1)
151
+
152
+ decided = draft.replace(
153
+ 'layout-1-text-1: TODO文本角色',
154
+ 'layout-1-text-1: title',
155
+ )
156
+ layouts_md = build_layouts_md(split_top_blocks(decided), (1920, 1080))
157
+
158
+ self.assertNotIn('text_roles:', layouts_md)
159
+ self.assertEqual(layouts_md.count('box: [120, 80, 840, 120]'), 1)
160
+ self.assertIn(
161
+ 'role: title, box: [120, 80, 840, 120], type: title',
162
+ layouts_md,
163
+ )
164
+ self.assertIn('css: "font-size: 48px; color: #C41230"', layouts_md)
165
+
166
+
167
+ if __name__ == '__main__':
168
+ unittest.main()