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

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 (82) hide show
  1. package/package.json +6 -6
  2. package/steering/design-html/skills/charts/SKILL.md +15 -4
  3. package/steering/design-html/skills/data-viz/SKILL.md +1 -1
  4. package/steering/design-html/skills/hi-fi-design/SKILL.md +1 -1
  5. package/steering/design-html/skills/interactive-prototype/SKILL.md +1 -17
  6. package/steering/design-html/skills/pptx-style-extract/SKILL.md +71 -26
  7. package/steering/design-html/skills/pptx-style-extract/font-fallback.yaml +3 -3
  8. package/steering/design-html/skills/pptx-style-extract/scripts/census.py +26 -14
  9. package/steering/design-html/skills/pptx-style-extract/scripts/check_v2.py +153 -8
  10. package/steering/design-html/skills/pptx-style-extract/scripts/draft.py +2831 -293
  11. package/steering/design-html/skills/pptx-style-extract/scripts/extract.py +544 -22
  12. package/steering/design-html/skills/pptx-style-extract/scripts/ooxml.py +19 -2
  13. package/steering/design-html/skills/pptx-style-extract/scripts/package.py +991 -165
  14. package/steering/design-html/skills/pptx-style-extract/scripts/parts.py +22 -3
  15. package/steering/design-html/skills/pptx-style-extract/scripts/query.py +4 -9
  16. package/steering/design-html/skills/pptx-style-extract/scripts/render_pages.py +20 -12
  17. package/steering/design-html/skills/pptx-style-extract/scripts/test_asset_judgment_package.py +556 -0
  18. package/steering/design-html/skills/pptx-style-extract/scripts/test_background_composite.py +364 -0
  19. package/steering/design-html/skills/pptx-style-extract/scripts/test_color_contract.py +60 -0
  20. package/steering/design-html/skills/pptx-style-extract/scripts/test_design_consumer_contract.py +76 -0
  21. package/steering/design-html/skills/pptx-style-extract/scripts/test_flow_layout_contract.py +528 -0
  22. package/steering/design-html/skills/pptx-style-extract/scripts/test_layout_css.py +1814 -0
  23. package/steering/design-html/skills/pptx-style-extract/scripts/test_logo_scope.py +600 -0
  24. package/steering/design-html/skills/pptx-style-extract/scripts/test_rounded_contract.py +112 -0
  25. package/steering/design-html/skills/pptx-style-extract/scripts/test_text_role_contract.py +315 -0
  26. package/steering/design-html/skills/pptx-style-extract/scripts/verify_layout_assets.py +421 -0
  27. package/steering/design-html/skills/pptx-style-extract/scripts/verify_logo_scope.py +12 -0
  28. package/steering/design-html/skills/pptx-style-extract/v2-format-spec.md +27 -15
  29. package/steering/design-html/skills/slide-deck/SKILL.md +5 -3
  30. package/steering/design-html/skills/social-card/SKILL.md +263 -0
  31. package/steering/nestjs-react-fullstack/skills/app-init-feasibility-guide/SKILL.md +4 -0
  32. package/steering/nestjs-react-fullstack/skills/authn-guide/SKILL.md +6 -0
  33. package/steering/nestjs-react-fullstack/skills/authz-guide/SKILL.md +5 -5
  34. package/steering/nestjs-react-fullstack/skills/authz-guide/references/dynamic-permission-guide.md +1 -1
  35. package/steering/nestjs-react-fullstack/skills/client-builtins-file-storage-service/SKILL.md +38 -113
  36. package/steering/nestjs-react-fullstack/skills/client-builtins-user-service/SKILL.md +15 -2
  37. package/steering/nestjs-react-fullstack/skills/code-fix/SKILL.md +13 -7
  38. package/steering/nestjs-react-fullstack/skills/coding-guide/SKILL.md +205 -38
  39. package/steering/nestjs-react-fullstack/skills/connections-sdk/SKILL.md +202 -0
  40. package/steering/nestjs-react-fullstack/skills/contacts-service/SKILL.md +67 -4
  41. package/steering/nestjs-react-fullstack/skills/nestjs-cache/SKILL.md +254 -0
  42. package/steering/nestjs-react-fullstack/skills/openapi-guide/SKILL.md +75 -0
  43. package/steering/nestjs-react-fullstack/skills/plugin-guide/SKILL.md +164 -543
  44. package/steering/nestjs-react-fullstack/skills/plugin-guide/references/plugin-coding-guide.md +72 -15
  45. package/steering/nestjs-react-fullstack/skills/plugin-guide/references/table.md +30 -14
  46. package/steering/nestjs-react-fullstack/skills/raw-sql-boundary-audit/SKILL.md +63 -0
  47. package/steering/nestjs-react-fullstack/skills/server-builtins-file-storage-service/SKILL.md +1 -1
  48. package/steering/nestjs-react-fullstack/skills_common/mcp-guide/SKILL.md +78 -0
  49. package/steering/nestjs-react-fullstack/skills_common/mcp-guide/assets/eslint.mcp-ui.config.cjs +19 -0
  50. package/steering/nestjs-react-fullstack/skills_common/mcp-guide/assets/ui-tsconfig.json +19 -0
  51. package/steering/nestjs-react-fullstack/skills_common/mcp-guide/references/client-onboarding.md +90 -0
  52. package/steering/nestjs-react-fullstack/skills_common/mcp-guide/references/debugging.md +81 -0
  53. package/steering/nestjs-react-fullstack/skills_common/mcp-guide/references/mcp-apps.md +197 -0
  54. package/steering/nestjs-react-fullstack/skills_common/mcp-guide/references/tool-authoring.md +248 -0
  55. package/steering/nestjs-react-fullstack/skills_common/trigger-guide/SKILL.md +284 -12
  56. package/steering/nestjs-react-fullstack/skills_common/user-identity/SKILL.md +3 -1
  57. package/steering/nestjs-react-fullstack/skills_local/client-builtins-user-service/SKILL.md +2 -0
  58. package/steering/nestjs-react-fullstack/skills_local/code-fix/SKILL.md +6 -0
  59. package/steering/nestjs-react-fullstack/skills_local/coding-guide/SKILL.md +49 -22
  60. package/steering/nestjs-react-fullstack/skills_local/plugin-guide/SKILL.md +11 -3
  61. package/steering/nestjs-react-fullstack/skills_local/plugin-guide/references/plugin-coding-guide.md +57 -14
  62. package/steering/vite-react/skills/plugin-guide/SKILL.md +4 -1
  63. package/steering/vite-react/skills/react-three-fiber/SKILL.md +5 -0
  64. package/steering/vite-react/tech.md +3 -0
  65. package/steering/design-html/skills/preflight/SKILL.md +0 -51
  66. package/steering/design-html/skills/preflight/scripts/probe.sh +0 -108
  67. package/steering/nestjs-react-fullstack/skills/client-add-aily-web-chat/SKILL.md +0 -139
  68. package/steering/nestjs-react-fullstack/skills/feishu/SKILL.md +0 -269
  69. package/steering/nestjs-react-fullstack/skills/feishu/references/approval.md +0 -214
  70. package/steering/nestjs-react-fullstack/skills/feishu/references/attendance.md +0 -163
  71. package/steering/nestjs-react-fullstack/skills/feishu/references/bitable.md +0 -311
  72. package/steering/nestjs-react-fullstack/skills/feishu/references/calendar.md +0 -190
  73. package/steering/nestjs-react-fullstack/skills/feishu/references/contacts.md +0 -160
  74. package/steering/nestjs-react-fullstack/skills/feishu/references/doc.md +0 -257
  75. package/steering/nestjs-react-fullstack/skills/feishu/references/drive.md +0 -104
  76. package/steering/nestjs-react-fullstack/skills/feishu/references/events.md +0 -199
  77. package/steering/nestjs-react-fullstack/skills/feishu/references/id-convert.md +0 -128
  78. package/steering/nestjs-react-fullstack/skills/feishu/references/messaging.md +0 -207
  79. package/steering/nestjs-react-fullstack/skills/feishu/references/oauth.md +0 -165
  80. package/steering/nestjs-react-fullstack/skills/feishu/references/perm.md +0 -91
  81. package/steering/nestjs-react-fullstack/skills/feishu/references/wiki.md +0 -165
  82. package/steering/nestjs-react-fullstack/skills_common/trigger-guide/references/trigger-lifecycle.md +0 -301
@@ -0,0 +1,1814 @@
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
+ from unittest import mock
8
+
9
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
10
+
11
+ import draft # noqa: E402
12
+ from check_v2 import Pack, rule_v2_16 # noqa: E402
13
+ from draft import (asset_vision_contexts, bound_visual_candidates, # noqa: E402
14
+ build_asset_vision_groups, contact_sheets, draft_layouts,
15
+ cover_background_media,
16
+ emit_asset_vision_groups, emit_layouts, emit_manifest,
17
+ fullscreen_overlay_media,
18
+ layouts_from_template,
19
+ needs_asset_judgment, preserve_image_bearing_groups,
20
+ select_asset_vision_groups, slide_image_marks, slot_style,
21
+ visual_slot_candidates)
22
+ from package import (FONTSIZE_RE, Fail, apply_asset_decisions, # noqa: E402
23
+ build_layouts_md, resolve_asset_candidate_lines,
24
+ split_top_blocks)
25
+
26
+
27
+ class LayoutCssTest(unittest.TestCase):
28
+ def test_template_layouts_use_sampled_theme_as_default_and_keep_theme_scope(self):
29
+ dark_master = 'ppt/slideMasters/slideMaster1.xml'
30
+ light_master = 'ppt/slideMasters/slideMaster2.xml'
31
+ dark_layout = 'ppt/slideLayouts/slideLayout1.xml'
32
+ light_layout = 'ppt/slideLayouts/slideLayout2.xml'
33
+ data = {
34
+ 'layouts': [
35
+ {'part': dark_layout, 'name': 'Dark content', 'used_by_slides': 2},
36
+ {'part': light_layout, 'name': 'Light content', 'used_by_slides': 0},
37
+ ],
38
+ 'slides': [],
39
+ 'theme_topology': {
40
+ 'themes': ['light', 'dark'],
41
+ 'default': 'light',
42
+ 'per_master': [
43
+ {'master': dark_master, 'theme_label': 'dark'},
44
+ {'master': light_master, 'theme_label': 'light'},
45
+ ],
46
+ },
47
+ 'reference_graph': {
48
+ 'master_of_layout': {
49
+ dark_layout: dark_master,
50
+ light_layout: light_master,
51
+ },
52
+ 'layout_of_slide': {
53
+ 'ppt/slides/slide1.xml': dark_layout,
54
+ 'ppt/slides/slide2.xml': dark_layout,
55
+ },
56
+ },
57
+ 'background_composites': {},
58
+ }
59
+ shapes = [
60
+ {
61
+ 'part': part,
62
+ 'layer': 'layout',
63
+ 'kind': 'sp',
64
+ 'box': {'x': 100, 'y': 100, 'w': 800, 'h': 100},
65
+ 'ph': {'type': 'title', 'idx': '1'},
66
+ 'text': {'paragraphs': [{'runs': [{'text': 'Title', 'sz_px': 40}]}]},
67
+ }
68
+ for part in (dark_layout, light_layout)
69
+ ]
70
+
71
+ archetypes = layouts_from_template(data, shapes, 1920, 1080)
72
+
73
+ self.assertEqual('dark', data['theme_topology']['default'])
74
+ self.assertEqual(
75
+ {'content': 'dark', 'content-2': 'light'},
76
+ {item['name']: item['theme'] for item in archetypes},
77
+ )
78
+ with tempfile.TemporaryDirectory() as output_dir:
79
+ emit_layouts(archetypes, output_dir)
80
+ emit_manifest(data, [], [], output_dir, archetypes)
81
+ with open(os.path.join(output_dir, 'layouts.yaml'), encoding='utf-8') as stream:
82
+ layouts = stream.read()
83
+ with open(os.path.join(output_dir, 'manifest.yaml'), encoding='utf-8') as stream:
84
+ manifest = stream.read()
85
+ self.assertIn(' themes: [dark]', layouts)
86
+ self.assertIn(' themes: [light]', layouts)
87
+ self.assertIn('default-theme: dark', manifest)
88
+
89
+ def test_template_twin_layouts_keep_topology_but_scope_retained_layout(self):
90
+ dark_master = 'ppt/slideMasters/slideMaster1.xml'
91
+ light_master = 'ppt/slideMasters/slideMaster2.xml'
92
+ dark_layout = 'ppt/slideLayouts/slideLayout1.xml'
93
+ light_layout = 'ppt/slideLayouts/slideLayout2.xml'
94
+ data = {
95
+ 'layouts': [
96
+ {'part': dark_layout, 'name': 'Content', 'used_by_slides': 2},
97
+ {'part': light_layout, 'name': 'Content', 'used_by_slides': 0},
98
+ ],
99
+ 'slides': [],
100
+ 'theme_topology': {
101
+ 'themes': ['light', 'dark'],
102
+ 'default': 'light',
103
+ 'per_master': [
104
+ {'master': dark_master, 'theme_label': 'dark'},
105
+ {'master': light_master, 'theme_label': 'light'},
106
+ ],
107
+ },
108
+ 'reference_graph': {
109
+ 'master_of_layout': {
110
+ dark_layout: dark_master,
111
+ light_layout: light_master,
112
+ },
113
+ 'layout_of_slide': {
114
+ 'ppt/slides/slide1.xml': dark_layout,
115
+ 'ppt/slides/slide2.xml': dark_layout,
116
+ },
117
+ },
118
+ 'background_composites': {},
119
+ }
120
+ shapes = [
121
+ {
122
+ 'part': part,
123
+ 'layer': 'layout',
124
+ 'kind': 'sp',
125
+ 'box': {'x': 100, 'y': 100, 'w': 800, 'h': 100},
126
+ 'ph': {'type': 'title', 'idx': '1'},
127
+ 'text': {'paragraphs': [{'runs': [{'text': 'Title', 'sz_px': 40}]}]},
128
+ }
129
+ for part in (dark_layout, light_layout)
130
+ ]
131
+
132
+ archetypes = layouts_from_template(data, shapes, 1920, 1080)
133
+
134
+ self.assertEqual(1, len(archetypes))
135
+ self.assertEqual('dark', archetypes[0]['theme'])
136
+ with tempfile.TemporaryDirectory() as output_dir:
137
+ emit_layouts(archetypes, output_dir)
138
+ emit_manifest(data, [], [], output_dir, archetypes)
139
+ with open(os.path.join(output_dir, 'layouts.yaml'), encoding='utf-8') as stream:
140
+ layouts = stream.read()
141
+ with open(os.path.join(output_dir, 'manifest.yaml'), encoding='utf-8') as stream:
142
+ manifest = stream.read()
143
+ self.assertIn(' themes: [dark]', layouts)
144
+ self.assertIn('themes: [light, dark]', manifest)
145
+ self.assertIn('default-theme: dark', manifest)
146
+
147
+ def test_text_slot_emits_rendering_style_as_css_only(self):
148
+ shape = {
149
+ 'text': {
150
+ 'bodyPr': {
151
+ 'anchor': 'ctr',
152
+ 'insets_px': {'lIns': 12, 'tIns': 8, 'rIns': 10, 'bIns': 6},
153
+ 'rot': '900000',
154
+ },
155
+ 'lstStyle': {
156
+ 'lvl1pPr': {
157
+ 'sz_px': 48,
158
+ 'weight': 600,
159
+ 'italic': True,
160
+ 'underline': 'sng',
161
+ 'strike': 'sngStrike',
162
+ 'spc_px': 1.5,
163
+ 'color': {'resolved': '#123456'},
164
+ 'algn': 'ctr',
165
+ 'lnSpc': {'mult': 1.0},
166
+ },
167
+ },
168
+ 'paragraphs': [],
169
+ },
170
+ }
171
+ slot = {
172
+ 'role': 'title',
173
+ 'type': 'title',
174
+ 'box': [100, 80, 800, 160],
175
+ 'sz': 48,
176
+ 'txt': '标题',
177
+ }
178
+ slot.update(slot_style(shape))
179
+ archetype = {
180
+ 'name': 'layout-1',
181
+ 'zh': '标题页',
182
+ 'role': 'content',
183
+ 'bg': None,
184
+ 'slots': [slot],
185
+ 'decor': [],
186
+ 'pages': [1],
187
+ 'rep': 1,
188
+ 'pic_n': 0,
189
+ 'confidence': 'high',
190
+ }
191
+
192
+ with tempfile.TemporaryDirectory() as output_dir:
193
+ emit_layouts([archetype], output_dir)
194
+ with open(os.path.join(output_dir, 'layouts.yaml'), encoding='utf-8') as stream:
195
+ layouts_yaml = stream.read()
196
+ with open(os.path.join(output_dir, 'layout-controls.yaml'), encoding='utf-8') as stream:
197
+ layout_controls = stream.read()
198
+ layouts_md = build_layouts_md(split_top_blocks(layouts_yaml), (1920, 1080))
199
+
200
+ self.assertIn('names:', layout_controls)
201
+ self.assertNotIn('\nlayouts:\n', layout_controls)
202
+ self.assertNotIn('box: [100, 80, 800, 160]', layout_controls)
203
+ self.assertIn(
204
+ 'css: "box-sizing: border-box; padding: 8px 10px 6px 12px; '
205
+ 'font-size: 48px; font-weight: 600; font-style: italic; '
206
+ 'text-decoration: underline line-through; letter-spacing: 1.5px; color: #123456; '
207
+ 'text-align: center; line-height: 1.2; display: flex; '
208
+ 'flex-direction: column; justify-content: center; rotate: 15deg"',
209
+ layouts_md,
210
+ )
211
+ for legacy_key in ('size', 'weight', 'color', 'align', 'valign', 'insets_px'):
212
+ self.assertNotRegex(layouts_md, rf'[,{{]\s*{legacy_key}:')
213
+ self.assertEqual(FONTSIZE_RE.findall(layouts_md), ['48'])
214
+
215
+ def test_layout_gate_rejects_legacy_slot_style_keys(self):
216
+ with tempfile.TemporaryDirectory() as pack_dir:
217
+ with open(os.path.join(pack_dir, 'design.md'), 'w', encoding='utf-8') as stream:
218
+ stream.write('---\nversion: alpha\nlayouts: layouts.md\n---\n\nRead layouts.md.\n')
219
+ with open(os.path.join(pack_dir, 'layouts.md'), 'w', encoding='utf-8') as stream:
220
+ stream.write(
221
+ '---\ncanvas: 1920x1080\nlayouts:\n cover:\n role: cover\n'
222
+ ' slots:\n - {role: title, type: title, box: [0, 0, 800, 100], '
223
+ 'size: 48, align: center}\n confidence: high\n---\n'
224
+ )
225
+
226
+ result = rule_v2_16(Pack(pack_dir))
227
+
228
+ self.assertEqual(result.level, 'FAIL')
229
+ self.assertEqual(len(result.fails), 1)
230
+ self.assertIn('旧样式键 align/size', result.fails[0])
231
+
232
+ def test_normautofit_fontscale_shrinks_emitted_font_size(self):
233
+ # 章节大号数字:160px 字号靠 normAutofit fontScale 0.9 装进 144px 的框。
234
+ # 不乘 fontScale,消费端拿到 160px,字比框高,渐变裁切把底部切成透明。
235
+ shape = {
236
+ 'text': {
237
+ 'bodyPr': {'anchor': 't', 'font_scale': 0.9, 'ln_spc_reduction': 0.1},
238
+ 'lstStyle': {'lvl1pPr': {'sz_px': 160, 'lnSpc': {'mult': 1.0}}},
239
+ 'paragraphs': [{'runs': [{'text': '01.'}]}],
240
+ },
241
+ }
242
+ style = slot_style(shape)
243
+ self.assertIn('font-size: 144px', style['css'])
244
+ self.assertNotIn('font-size: 160px', style['css'])
245
+ # lnSpcReduction 0.1 把 1.0*1.2 的行高压到 1.08
246
+ self.assertIn('line-height: 1.08', style['css'])
247
+
248
+ def test_missing_autofit_leaves_font_size_untouched(self):
249
+ # 无 normAutofit(或无 fontScale)时零影响:字号原样、行高不缩。
250
+ shape = {
251
+ 'text': {
252
+ 'bodyPr': {'anchor': 't'},
253
+ 'lstStyle': {'lvl1pPr': {'sz_px': 160, 'lnSpc': {'mult': 1.0}}},
254
+ 'paragraphs': [{'runs': [{'text': '01.'}]}],
255
+ },
256
+ }
257
+ style = slot_style(shape)
258
+ self.assertIn('font-size: 160px', style['css'])
259
+ self.assertIn('line-height: 1.2', style['css'])
260
+
261
+ def test_run_level_style_is_emitted_from_flat_ooxml_record(self):
262
+ # read_txbody 把 a:rPr 的属性直接展开到 run;没有嵌套 rPr 字段。
263
+ shape = {
264
+ 'text': {
265
+ 'bodyPr': {'anchor': 't'},
266
+ 'paragraphs': [{
267
+ 'runs': [{
268
+ 'text': '品牌标题',
269
+ 'sz_px': 52,
270
+ 'weight': 700,
271
+ 'latin': 'Brand Sans',
272
+ 'color': {'resolved': '#C1121F'},
273
+ }],
274
+ }],
275
+ },
276
+ }
277
+
278
+ style = slot_style(shape)
279
+
280
+ self.assertIn('font-size: 52px', style['css'])
281
+ self.assertIn(
282
+ 'font-family: "Brand Sans", "PingFang SC", "Microsoft YaHei", sans-serif',
283
+ style['css'],
284
+ )
285
+ self.assertIn('font-weight: 700', style['css'])
286
+ self.assertIn('color: #C1121F', style['css'])
287
+
288
+ def test_run_level_style_overrides_inherited_list_style(self):
289
+ shape = {
290
+ 'text': {
291
+ 'lstStyle': {
292
+ 'lvl1pPr': {
293
+ 'sz_px': 20,
294
+ 'latin': 'Inherited Sans',
295
+ 'color': {'resolved': '#111111'},
296
+ },
297
+ },
298
+ 'paragraphs': [{
299
+ 'runs': [{
300
+ 'text': '直接格式',
301
+ 'sz_px': 52,
302
+ 'latin': 'Direct Sans',
303
+ 'color': {'resolved': '#C1121F'},
304
+ }],
305
+ }],
306
+ },
307
+ }
308
+
309
+ style = slot_style(shape)
310
+
311
+ self.assertIn('font-size: 52px', style['css'])
312
+ self.assertIn('font-family: "Direct Sans"', style['css'])
313
+ self.assertIn('color: #C1121F', style['css'])
314
+ self.assertNotIn('font-size: 20px', style['css'])
315
+ self.assertNotIn('Inherited Sans', style['css'])
316
+
317
+ def test_asset_decisions_keep_content_slots_and_bind_texture_slots(self):
318
+ manifest = {
319
+ 'assets': [],
320
+ 'asset_decisions': [
321
+ {'source_media': 'chart.png', 'decision': 'content'},
322
+ {'source_media': 'ornament.png', 'decision': 'texture'},
323
+ ],
324
+ }
325
+
326
+ decisions, asset_ids = apply_asset_decisions(manifest)
327
+ lines = resolve_asset_candidate_lines([
328
+ ' - {role: asset-candidate, box: [10, 20, 300, 200], type: pic, '
329
+ 'source_media: chart.png}',
330
+ ' - {role: asset-candidate, box: [20, 30, 80, 80], type: pic, '
331
+ 'source_media: ornament.png}',
332
+ ], decisions, asset_ids)
333
+
334
+ self.assertEqual(len(lines), 2)
335
+ self.assertNotIn('chart.png', lines[0])
336
+ self.assertNotIn('source_media', lines[0])
337
+ self.assertNotIn('asset:', lines[0])
338
+ self.assertIn('role: pic', lines[0])
339
+ self.assertNotIn('source_media', lines[1])
340
+ self.assertIn('role: texture', lines[1])
341
+ self.assertIn('asset: texture-1', lines[1])
342
+ self.assertEqual(manifest['assets'], [{
343
+ 'id': 'texture-1',
344
+ 'source_media': 'ornament.png',
345
+ 'kind': 'texture',
346
+ }])
347
+
348
+ def test_faas_visual_groups_map_content_and_decorative_without_leaking_to_pack(self):
349
+ manifest = {
350
+ 'assets': [],
351
+ 'asset_vision_groups': [
352
+ {
353
+ 'id': 'vision-1',
354
+ 'source_media': '[partner-logo.png, ornament.png]',
355
+ 'visual_kind': 'content-image',
356
+ },
357
+ {
358
+ 'id': 'vision-2',
359
+ 'source_media': '[illustration.png]',
360
+ 'visual_kind': 'illustration',
361
+ },
362
+ ],
363
+ 'asset_decisions': [
364
+ {'source_media': 'ornament.png', 'visual_kind': 'decorative'},
365
+ ],
366
+ }
367
+
368
+ decisions, asset_ids = apply_asset_decisions(manifest)
369
+ lines = resolve_asset_candidate_lines([
370
+ ' - {role: asset-candidate, box: [10, 20, 300, 200], type: pic, '
371
+ 'source_media: partner-logo.png}',
372
+ ' - {role: asset-candidate, box: [20, 30, 80, 80], type: pic, '
373
+ 'source_media: ornament.png}',
374
+ ' - {role: asset-candidate, box: [40, 50, 60, 60], type: pic, '
375
+ 'source_media: illustration.png}',
376
+ ], decisions, asset_ids)
377
+
378
+ self.assertEqual(decisions['partner-logo.png'], 'content')
379
+ self.assertEqual(decisions['ornament.png'], 'texture')
380
+ self.assertEqual(decisions['illustration.png'], 'texture')
381
+ self.assertEqual(
382
+ [asset['kind'] for asset in manifest['assets']],
383
+ ['texture', 'texture'],
384
+ )
385
+ self.assertEqual(lines[0], ' - {role: pic, box: [10, 20, 300, 200], type: pic}')
386
+ self.assertIn('asset: texture-1', lines[1])
387
+ self.assertIn('asset: texture-2', lines[2])
388
+
389
+ def test_same_source_same_box_uses_its_layout_specific_vision_decision(self):
390
+ manifest = {
391
+ 'assets': [],
392
+ 'asset_vision_groups': [
393
+ {
394
+ 'id': 'vision-1',
395
+ 'source_media': 'shared.png',
396
+ 'layout': 'layout-1',
397
+ 'box': '[100, 200, 80, 80]',
398
+ 'visual_kind': 'content-image',
399
+ },
400
+ {
401
+ 'id': 'vision-2',
402
+ 'source_media': 'shared.png',
403
+ 'layout': 'layout-2',
404
+ 'box': '[100, 200, 80, 80]',
405
+ 'visual_kind': 'decorative',
406
+ },
407
+ ],
408
+ }
409
+
410
+ decisions, asset_ids = apply_asset_decisions(manifest)
411
+ lines = resolve_asset_candidate_lines([
412
+ ' layout-1:',
413
+ ' slots:',
414
+ ' - {role: asset-candidate, box: [100, 200, 80, 80], type: pic, '
415
+ 'source_media: shared.png}',
416
+ ' layout-2:',
417
+ ' slots:',
418
+ ' - {role: asset-candidate, box: [100, 200, 80, 80], type: pic, '
419
+ 'source_media: shared.png}',
420
+ ], decisions, asset_ids)
421
+
422
+ self.assertEqual(lines[2], ' - {role: pic, box: [100, 200, 80, 80], type: pic}')
423
+ self.assertIn('role: texture', lines[5])
424
+ self.assertIn('asset: texture-1', lines[5])
425
+
426
+ def test_build_layouts_keeps_instance_only_asset_decisions(self):
427
+ manifest = {
428
+ 'assets': [],
429
+ 'asset_vision_groups': [{
430
+ 'id': 'vision-1',
431
+ 'source_media': 'cover-illustration.png',
432
+ 'layout': 'layout-1',
433
+ 'box': '[100, 200, 80, 80]',
434
+ 'visual_kind': 'illustration',
435
+ }],
436
+ }
437
+ decisions, asset_ids = apply_asset_decisions(manifest)
438
+ layouts_yaml = '''\
439
+ names:
440
+ layout-1: "封面"
441
+ roles:
442
+ layout-1: cover
443
+ layouts:
444
+ layout-1:
445
+ role: content
446
+ slots:
447
+ - {role: asset-candidate, box: [100, 200, 80, 80], type: pic, source_media: cover-illustration.png}
448
+ confidence: high
449
+ '''
450
+
451
+ layouts_md = build_layouts_md(
452
+ split_top_blocks(layouts_yaml), (1920, 1080), decisions, asset_ids)
453
+
454
+ self.assertIn(
455
+ '{role: texture, box: [100, 200, 80, 80], type: pic, asset: texture-1}',
456
+ layouts_md,
457
+ )
458
+
459
+ def test_duplicate_instance_vision_decisions_are_idempotent(self):
460
+ manifest = {
461
+ 'assets': [],
462
+ 'asset_vision_groups': [
463
+ {
464
+ 'id': 'vision-1',
465
+ 'source_media': 'cover-illustration.png',
466
+ 'layout': 'layout-1',
467
+ 'box': '[100, 200, 80, 80]',
468
+ 'visual_kind': 'illustration',
469
+ },
470
+ {
471
+ 'id': 'vision-2',
472
+ 'source_media': 'cover-illustration.png',
473
+ 'layout': 'layout-1',
474
+ 'box': '[100, 200, 80, 80]',
475
+ 'visual_kind': 'illustration',
476
+ },
477
+ ],
478
+ 'asset_decisions': [{
479
+ 'source_media': 'cover-illustration.png',
480
+ 'layout': 'layout-1',
481
+ 'box': '[100, 200, 80, 80]',
482
+ 'visual_kind': 'illustration',
483
+ }],
484
+ }
485
+
486
+ decisions, asset_ids = apply_asset_decisions(manifest)
487
+ lines = resolve_asset_candidate_lines([
488
+ ' layout-1:',
489
+ ' slots:',
490
+ ' - {role: asset-candidate, box: [100, 200, 80, 80], type: pic, '
491
+ 'source_media: cover-illustration.png}',
492
+ ], decisions, asset_ids)
493
+
494
+ self.assertIn('role: texture', lines[2])
495
+ self.assertIn('asset: texture-1', lines[2])
496
+ self.assertEqual(manifest['assets'], [{
497
+ 'id': 'texture-1',
498
+ 'source_media': 'cover-illustration.png',
499
+ 'kind': 'texture',
500
+ }])
501
+
502
+ def test_duplicate_instance_vision_decisions_reject_conflicting_kinds(self):
503
+ manifest = {
504
+ 'assets': [],
505
+ 'asset_vision_groups': [
506
+ {
507
+ 'id': 'vision-1',
508
+ 'source_media': 'cover-illustration.png',
509
+ 'layout': 'layout-1',
510
+ 'box': '[100, 200, 80, 80]',
511
+ 'visual_kind': 'illustration',
512
+ },
513
+ {
514
+ 'id': 'vision-2',
515
+ 'source_media': 'cover-illustration.png',
516
+ 'layout': 'layout-1',
517
+ 'box': '[100, 200, 80, 80]',
518
+ 'visual_kind': 'decorative',
519
+ },
520
+ ],
521
+ }
522
+
523
+ with self.assertRaisesRegex(Fail, '实例判断冲突'):
524
+ apply_asset_decisions(manifest)
525
+
526
+ def test_asset_decision_overrides_a_vision_group_instance(self):
527
+ manifest = {
528
+ 'assets': [],
529
+ 'asset_vision_groups': [{
530
+ 'id': 'vision-1',
531
+ 'source_media': 'cover-illustration.png',
532
+ 'layout': 'layout-1',
533
+ 'box': '[100, 200, 80, 80]',
534
+ 'visual_kind': 'illustration',
535
+ }],
536
+ 'asset_decisions': [{
537
+ 'source_media': 'cover-illustration.png',
538
+ 'layout': 'layout-1',
539
+ 'box': '[100, 200, 80, 80]',
540
+ 'visual_kind': 'content-image',
541
+ }],
542
+ }
543
+
544
+ decisions, asset_ids = apply_asset_decisions(manifest)
545
+
546
+ self.assertEqual(
547
+ decisions.for_slot(
548
+ 'cover-illustration.png',
549
+ 'box: [100, 200, 80, 80]',
550
+ 'layout-1',
551
+ ),
552
+ 'content',
553
+ )
554
+ self.assertEqual(asset_ids, {})
555
+ self.assertEqual(manifest['assets'], [])
556
+
557
+ def test_instance_content_override_removes_unused_source_asset(self):
558
+ manifest = {
559
+ 'assets': [{
560
+ 'id': 'texture-old',
561
+ 'source_media': 'cover-illustration.png',
562
+ 'kind': 'texture',
563
+ }],
564
+ 'asset_vision_groups': [{
565
+ 'id': 'vision-1',
566
+ 'source_media': 'cover-illustration.png',
567
+ 'visual_kind': 'illustration',
568
+ }],
569
+ 'asset_decisions': [{
570
+ 'source_media': 'cover-illustration.png',
571
+ 'layout': 'layout-1',
572
+ 'box': '[100, 200, 80, 80]',
573
+ 'visual_kind': 'content-image',
574
+ }],
575
+ }
576
+
577
+ decisions, asset_ids = apply_asset_decisions(
578
+ manifest,
579
+ bound_sources={'cover-illustration.png'},
580
+ bound_slots={
581
+ 'cover-illustration.png': {('layout-1', (100, 200, 80, 80))},
582
+ },
583
+ )
584
+
585
+ self.assertEqual(
586
+ decisions.for_slot(
587
+ 'cover-illustration.png',
588
+ 'box: [100, 200, 80, 80]',
589
+ 'layout-1',
590
+ ),
591
+ 'content',
592
+ )
593
+ self.assertEqual(asset_ids, {})
594
+ self.assertEqual(manifest['assets'], [])
595
+
596
+ def test_instance_decision_requires_an_exact_layout_slot(self):
597
+ manifest = {
598
+ 'assets': [],
599
+ 'asset_vision_groups': [{
600
+ 'id': 'vision-1',
601
+ 'source_media': 'cover-illustration.png',
602
+ 'layout': 'layout-mistyped',
603
+ 'box': '[100, 200, 80, 80]',
604
+ 'visual_kind': 'illustration',
605
+ }],
606
+ }
607
+
608
+ with self.assertRaisesRegex(Fail, '实例没有对应图片槽'):
609
+ apply_asset_decisions(
610
+ manifest,
611
+ bound_sources={'cover-illustration.png'},
612
+ bound_slots={
613
+ 'cover-illustration.png': {('layout-1', (100, 200, 80, 80))},
614
+ },
615
+ )
616
+
617
+ def test_instance_decision_without_layout_matches_any_layout_at_its_box(self):
618
+ manifest = {
619
+ 'assets': [],
620
+ 'asset_vision_groups': [{
621
+ 'id': 'vision-1',
622
+ 'source_media': 'cover-illustration.png',
623
+ 'box': '[100, 200, 80, 80]',
624
+ 'visual_kind': 'illustration',
625
+ }],
626
+ }
627
+
628
+ _, asset_ids = apply_asset_decisions(
629
+ manifest,
630
+ bound_sources={'cover-illustration.png'},
631
+ bound_slots={
632
+ 'cover-illustration.png': {('layout-1', (100, 200, 80, 80))},
633
+ },
634
+ )
635
+
636
+ self.assertEqual(asset_ids, {'cover-illustration.png': 'texture-1'})
637
+
638
+ def test_source_asset_decision_overrides_a_vision_group_instance(self):
639
+ manifest = {
640
+ 'assets': [],
641
+ 'asset_vision_groups': [{
642
+ 'id': 'vision-1',
643
+ 'source_media': 'cover-illustration.png',
644
+ 'layout': 'layout-1',
645
+ 'box': '[100, 200, 80, 80]',
646
+ 'visual_kind': 'illustration',
647
+ }],
648
+ 'asset_decisions': [{
649
+ 'source_media': 'cover-illustration.png',
650
+ 'visual_kind': 'content-image',
651
+ }],
652
+ }
653
+
654
+ decisions, asset_ids = apply_asset_decisions(manifest)
655
+
656
+ self.assertEqual(
657
+ decisions.for_slot(
658
+ 'cover-illustration.png',
659
+ 'box: [100, 200, 80, 80]',
660
+ 'layout-1',
661
+ ),
662
+ 'content',
663
+ )
664
+ self.assertEqual(asset_ids, {})
665
+ self.assertEqual(manifest['assets'], [])
666
+
667
+ def test_source_asset_decision_overrides_every_instance_of_its_source(self):
668
+ manifest = {
669
+ 'assets': [],
670
+ 'asset_vision_groups': [
671
+ {
672
+ 'id': 'vision-1',
673
+ 'source_media': 'cover-illustration.png',
674
+ 'layout': 'layout-1',
675
+ 'box': '[100, 200, 80, 80]',
676
+ 'visual_kind': 'illustration',
677
+ },
678
+ {
679
+ 'id': 'vision-2',
680
+ 'source_media': 'cover-illustration.png',
681
+ 'layout': 'layout-2',
682
+ 'box': '[200, 200, 80, 80]',
683
+ 'visual_kind': 'decorative',
684
+ },
685
+ ],
686
+ 'asset_decisions': [{
687
+ 'source_media': 'cover-illustration.png',
688
+ 'visual_kind': 'content-image',
689
+ }],
690
+ }
691
+
692
+ decisions, asset_ids = apply_asset_decisions(manifest)
693
+
694
+ self.assertEqual(
695
+ decisions.for_scope(
696
+ 'cover-illustration.png', 'layout-1', (100, 200, 80, 80)),
697
+ 'content',
698
+ )
699
+ self.assertEqual(
700
+ decisions.for_scope(
701
+ 'cover-illustration.png', 'layout-2', (200, 200, 80, 80)),
702
+ 'content',
703
+ )
704
+ self.assertEqual(asset_ids, {})
705
+ self.assertEqual(manifest['assets'], [])
706
+
707
+ def test_asset_decisions_reject_conflicting_instance_kinds(self):
708
+ manifest = {
709
+ 'assets': [],
710
+ 'asset_decisions': [
711
+ {
712
+ 'source_media': 'cover-illustration.png',
713
+ 'layout': 'layout-1',
714
+ 'box': '[100, 200, 80, 80]',
715
+ 'visual_kind': 'illustration',
716
+ },
717
+ {
718
+ 'source_media': 'cover-illustration.png',
719
+ 'layout': 'layout-1',
720
+ 'box': '[100, 200, 80, 80]',
721
+ 'visual_kind': 'decorative',
722
+ },
723
+ ],
724
+ }
725
+
726
+ with self.assertRaisesRegex(Fail, '实例判断冲突'):
727
+ apply_asset_decisions(manifest)
728
+
729
+ def test_conflicting_background_roles_are_rejected(self):
730
+ manifest = {
731
+ 'assets': [],
732
+ 'asset_vision_groups': [
733
+ {
734
+ 'id': 'vision-1',
735
+ 'source_media': 'shared-background.png',
736
+ 'visual_kind': 'background',
737
+ 'role': 'cover',
738
+ },
739
+ {
740
+ 'id': 'vision-2',
741
+ 'source_media': 'shared-background.png',
742
+ 'visual_kind': 'background',
743
+ 'role': 'content',
744
+ },
745
+ ],
746
+ }
747
+
748
+ with self.assertRaisesRegex(Fail, 'source_media role 冲突'):
749
+ apply_asset_decisions(manifest)
750
+
751
+ def test_asset_decisions_reject_conflicting_background_roles(self):
752
+ manifest = {
753
+ 'assets': [],
754
+ 'asset_decisions': [
755
+ {
756
+ 'source_media': 'shared-background.png',
757
+ 'visual_kind': 'background',
758
+ 'role': 'cover',
759
+ },
760
+ {
761
+ 'source_media': 'shared-background.png',
762
+ 'visual_kind': 'background',
763
+ 'role': 'content',
764
+ },
765
+ ],
766
+ }
767
+
768
+ with self.assertRaisesRegex(Fail, 'source_media role 冲突'):
769
+ apply_asset_decisions(manifest)
770
+
771
+ def test_asset_decision_rejects_a_conflicting_vision_background_role(self):
772
+ manifest = {
773
+ 'assets': [],
774
+ 'asset_vision_groups': [{
775
+ 'id': 'vision-1',
776
+ 'source_media': 'shared-background.png',
777
+ 'visual_kind': 'background',
778
+ 'role': 'cover',
779
+ }],
780
+ 'asset_decisions': [{
781
+ 'source_media': 'shared-background.png',
782
+ 'visual_kind': 'background',
783
+ 'role': 'content',
784
+ }],
785
+ }
786
+
787
+ with self.assertRaisesRegex(Fail, 'source_media role 冲突'):
788
+ apply_asset_decisions(manifest)
789
+
790
+ def test_background_roles_conflict_at_the_same_slot_across_sources(self):
791
+ manifest = {
792
+ 'assets': [],
793
+ 'asset_vision_groups': [
794
+ {
795
+ 'id': 'vision-cover',
796
+ 'source_media': 'cover-bg.png',
797
+ 'layout': 'cover',
798
+ 'box': '[0, 0, 1920, 1080]',
799
+ 'visual_kind': 'background',
800
+ 'role': 'cover',
801
+ },
802
+ {
803
+ 'id': 'vision-content',
804
+ 'source_media': 'content-bg.png',
805
+ 'layout': 'cover',
806
+ 'box': '[0, 0, 1920, 1080]',
807
+ 'visual_kind': 'background',
808
+ 'role': 'content',
809
+ },
810
+ ],
811
+ }
812
+
813
+ with self.assertRaisesRegex(Fail, '背景实例 role 冲突'):
814
+ apply_asset_decisions(manifest)
815
+
816
+ def test_same_source_instances_bind_assets_by_their_final_decisions(self):
817
+ manifest = {
818
+ 'assets': [],
819
+ 'asset_vision_groups': [
820
+ {
821
+ 'id': 'vision-1',
822
+ 'source_media': 'shared.png',
823
+ 'layout': 'layout-1',
824
+ 'box': '[100, 200, 80, 80]',
825
+ 'visual_kind': 'logo',
826
+ },
827
+ {
828
+ 'id': 'vision-2',
829
+ 'source_media': 'shared.png',
830
+ 'layout': 'layout-2',
831
+ 'box': '[100, 200, 80, 80]',
832
+ 'visual_kind': 'decorative',
833
+ },
834
+ ],
835
+ }
836
+
837
+ decisions, asset_ids = apply_asset_decisions(manifest)
838
+ lines = resolve_asset_candidate_lines([
839
+ ' layout-1:',
840
+ ' slots:',
841
+ ' - {role: asset-candidate, box: [100, 200, 80, 80], type: pic, '
842
+ 'source_media: shared.png}',
843
+ ' layout-2:',
844
+ ' slots:',
845
+ ' - {role: asset-candidate, box: [100, 200, 80, 80], type: pic, '
846
+ 'source_media: shared.png}',
847
+ ], decisions, asset_ids)
848
+
849
+ self.assertIn('role: logo', lines[2])
850
+ self.assertIn('asset: logo-1', lines[2])
851
+ self.assertIn('role: texture', lines[5])
852
+ self.assertIn('asset: texture-1', lines[5])
853
+ self.assertEqual(
854
+ [(asset['id'], asset['kind']) for asset in manifest['assets']],
855
+ [('logo-1', 'logo'), ('texture-1', 'texture')],
856
+ )
857
+
858
+ def test_asset_decisions_do_not_reuse_an_existing_asset_id(self):
859
+ manifest = {
860
+ 'assets': [
861
+ {
862
+ 'id': 'logo-primary',
863
+ 'source_media': 'candidate.png',
864
+ 'kind': 'logo',
865
+ },
866
+ {
867
+ 'id': 'texture-1',
868
+ 'source_media': 'existing.png',
869
+ 'kind': 'texture',
870
+ },
871
+ ],
872
+ 'asset_decisions': [
873
+ {'source_media': 'candidate.png', 'decision': 'texture'},
874
+ ],
875
+ }
876
+
877
+ _, asset_ids = apply_asset_decisions(manifest)
878
+
879
+ self.assertEqual(asset_ids['existing.png'], 'texture-1')
880
+ self.assertEqual(asset_ids['candidate.png'], 'texture-2')
881
+ self.assertEqual(
882
+ [asset['id'] for asset in manifest['assets']],
883
+ ['texture-2', 'texture-1'],
884
+ )
885
+
886
+ def test_asset_decisions_reject_candidates_without_layout_slots(self):
887
+ manifest = {
888
+ 'assets': [],
889
+ 'asset_decisions': [
890
+ {'source_media': 'orphan-decoration.png', 'decision': 'texture'},
891
+ ],
892
+ }
893
+
894
+ with self.assertRaisesRegex(Fail, '没有对应图片槽'):
895
+ apply_asset_decisions(manifest, bound_sources=set())
896
+
897
+ def test_asset_vision_groups_reject_candidates_without_layout_slots(self):
898
+ manifest = {
899
+ 'assets': [],
900
+ 'asset_vision_groups': [{
901
+ 'id': 'vision-1',
902
+ 'source_media': '[orphan-decoration.png]',
903
+ 'visual_kind': 'decorative',
904
+ }],
905
+ }
906
+
907
+ with self.assertRaisesRegex(Fail, '没有对应图片槽'):
908
+ apply_asset_decisions(manifest, bound_sources=set())
909
+
910
+ def test_translucent_fullscreen_decoration_requires_model_judgment(self):
911
+ candidate = {
912
+ 'id': 'asset-1',
913
+ 'file': 'glow.png',
914
+ 'fullscreen': True,
915
+ 'slides': [2],
916
+ 'probe': {'alpha_mean': 80},
917
+ }
918
+
919
+ with tempfile.TemporaryDirectory() as output_dir:
920
+ emit_manifest({'theme_topology': {}}, [], [{
921
+ 'id': 'vision-1',
922
+ 'pages': [2],
923
+ 'candidates': [dict(candidate, placements=[{
924
+ 'slide': 2,
925
+ 'archetype': 'layout-2',
926
+ 'box': [10, 20, 30, 40],
927
+ }])],
928
+ }], output_dir)
929
+ with open(os.path.join(output_dir, 'manifest.yaml'), encoding='utf-8') as stream:
930
+ manifest = stream.read()
931
+
932
+ self.assertIn('source_media: glow.png', manifest)
933
+ self.assertNotIn(
934
+ ' layout: layout-2\n box: [10, 20, 30, 40]',
935
+ manifest,
936
+ )
937
+ self.assertIn('box: [10, 20, 30, 40]', manifest)
938
+ self.assertIn('visual_kind: TODO-visual-kind-asset-1', manifest)
939
+
940
+ def test_asset_vision_groups_follow_faas_page_budgets(self):
941
+ candidates = [
942
+ {
943
+ 'id': 'asset-%d' % index,
944
+ 'file': 'image-%d.png' % index,
945
+ 'placements': [{'slide': 2, 'box': [index, 0, 10, 10]}],
946
+ 'slides': [2],
947
+ }
948
+ for index in range(1, 11)
949
+ ] + [
950
+ {
951
+ 'id': 'asset-11',
952
+ 'file': 'image-11.png',
953
+ 'placements': [{'slide': 3, 'box': [0, 0, 10, 10]}],
954
+ 'slides': [3],
955
+ },
956
+ {
957
+ 'id': 'asset-12',
958
+ 'file': 'image-12.png',
959
+ 'placements': [{'slide': 4, 'box': [0, 0, 10, 10]}],
960
+ 'slides': [4],
961
+ },
962
+ ]
963
+
964
+ groups = build_asset_vision_groups(candidates)
965
+
966
+ self.assertEqual(
967
+ [(group['pages'], len(group['candidates']), group['input_count']) for group in groups],
968
+ [([2], 9, 10), ([2], 1, 2), ([3, 4], 2, 4)],
969
+ )
970
+
971
+ def test_asset_vision_selection_keeps_all_first_and_last_page_batches(self):
972
+ groups = [
973
+ {
974
+ 'id': 'vision-%d' % index,
975
+ 'pages': [index],
976
+ 'candidates': [],
977
+ 'input_count': 1,
978
+ }
979
+ for index in range(1, 9)
980
+ ] + [{
981
+ 'id': 'vision-9',
982
+ 'pages': [8],
983
+ 'candidates': [],
984
+ 'input_count': 1,
985
+ }]
986
+
987
+ selected, omitted = select_asset_vision_groups(groups, 8)
988
+
989
+ self.assertEqual([group['id'] for group in selected], [
990
+ 'vision-1', 'vision-2', 'vision-3', 'vision-8', 'vision-9',
991
+ ])
992
+ self.assertEqual([group['id'] for group in omitted], [
993
+ 'vision-4', 'vision-5', 'vision-6', 'vision-7',
994
+ ])
995
+
996
+ def test_asset_vision_selection_interleaves_first_and_last_page_batches(self):
997
+ groups = [
998
+ {
999
+ 'id': 'first-%d' % index,
1000
+ 'pages': [1],
1001
+ 'candidates': [],
1002
+ 'input_count': 1,
1003
+ }
1004
+ for index in range(1, 4)
1005
+ ] + [
1006
+ {
1007
+ 'id': 'last-%d' % index,
1008
+ 'pages': [8],
1009
+ 'candidates': [],
1010
+ 'input_count': 1,
1011
+ }
1012
+ for index in range(1, 4)
1013
+ ]
1014
+
1015
+ selected, omitted = select_asset_vision_groups(groups, 8)
1016
+
1017
+ self.assertEqual(
1018
+ {group['id'] for group in selected},
1019
+ {'first-1', 'first-2', 'first-3', 'last-1', 'last-2'},
1020
+ )
1021
+ self.assertEqual([group['id'] for group in omitted], ['last-3'])
1022
+
1023
+ def test_asset_vision_groups_degrade_when_pillow_is_unavailable(self):
1024
+ candidates = [{
1025
+ 'id': 'asset-1',
1026
+ 'file': 'ornament.png',
1027
+ 'out': 'media-out/ornament.png',
1028
+ 'probe': {'w': 80, 'h': 80},
1029
+ 'placements': [{'slide': 1, 'box': [10, 20, 80, 80]}],
1030
+ }]
1031
+
1032
+ with tempfile.TemporaryDirectory() as output_dir:
1033
+ lout_dir = os.path.join(output_dir, 'l-out')
1034
+ os.makedirs(lout_dir)
1035
+ with mock.patch.object(draft, 'has_pillow', return_value=False):
1036
+ selected, omitted, sheets = emit_asset_vision_groups(
1037
+ output_dir, candidates, 1, lout_dir)
1038
+
1039
+ self.assertEqual(selected, [])
1040
+ self.assertEqual(len(omitted), 1)
1041
+ self.assertEqual(sheets, [])
1042
+ self.assertTrue(os.path.isfile(
1043
+ os.path.join(lout_dir, 'asset-vision-groups.json')))
1044
+
1045
+ def test_brief_blocks_asset_judgment_when_visual_sheets_are_unavailable(self):
1046
+ data = {
1047
+ 'source': {'filename': 'template.pptx'},
1048
+ 'canvas': {'px': [1920, 1080]},
1049
+ 'counts': {'slides': 1, 'layouts': 1},
1050
+ 'theme_topology': {'themes': ['single']},
1051
+ 'form_hint': {'form': 2},
1052
+ }
1053
+ archetype = {'name': 'layout-1', 'pages': [1], 'rep': 1, 'bg': None, 'slots': []}
1054
+
1055
+ with tempfile.TemporaryDirectory() as output_dir:
1056
+ draft.emit_brief(
1057
+ data,
1058
+ ([], [], [], {}, [], [], [], [archetype], [], [],
1059
+ [], [{'id': 'vision-1', 'pages': [1], 'candidates': []}], [], None),
1060
+ output_dir,
1061
+ )
1062
+ with open(os.path.join(output_dir, 'BRIEF.md'), encoding='utf-8') as stream:
1063
+ brief = stream.read()
1064
+
1065
+ self.assertIn('不要给图片候选定性', brief)
1066
+ self.assertNotIn('逐张看 `media-out/`', brief)
1067
+
1068
+ def test_brief_does_not_assign_unsampled_template_layouts_to_the_model(self):
1069
+ data = {
1070
+ 'source': {'filename': 'template.pptx'},
1071
+ 'canvas': {'px': [1920, 1080]},
1072
+ 'counts': {'slides': 1, 'layouts': 2},
1073
+ 'theme_topology': {'themes': ['single']},
1074
+ 'form_hint': {'form': 3},
1075
+ }
1076
+ sampled = {
1077
+ 'name': 'cover',
1078
+ 'pages': [1],
1079
+ 'rep': 1,
1080
+ 'bg': None,
1081
+ 'slots': [{'role': 'title', 'sz': 48, 'txt': '封面'}],
1082
+ }
1083
+ template_only = {
1084
+ 'name': 'content',
1085
+ 'pages': [],
1086
+ 'rep': None,
1087
+ 'bg': None,
1088
+ 'slots': [{'role': 'body', 'sz': 24, 'txt': '模板占位文本'}],
1089
+ }
1090
+
1091
+ with tempfile.TemporaryDirectory() as output_dir:
1092
+ draft.emit_brief(
1093
+ data,
1094
+ ([], [], [], {}, [], [], [], [sampled, template_only], [], [],
1095
+ [], [], [], None),
1096
+ output_dir,
1097
+ )
1098
+ with open(os.path.join(output_dir, 'BRIEF.md'), encoding='utf-8') as stream:
1099
+ brief = stream.read()
1100
+
1101
+ self.assertIn('另有 1 个模板声明版式没有对应样张', brief)
1102
+ self.assertIn('- `cover`(第 1 页,覆盖 [1])', brief)
1103
+ self.assertNotIn('模板占位文本', brief)
1104
+
1105
+ def test_brief_uses_form3_sample_pages_for_real_layout_mapping(self):
1106
+ data = {
1107
+ 'source': {'filename': 'template.pptx'},
1108
+ 'canvas': {'px': [1920, 1080]},
1109
+ 'counts': {'slides': 1, 'layouts': 2},
1110
+ 'theme_topology': {'themes': ['single']},
1111
+ 'form_hint': {'form': 3},
1112
+ }
1113
+ sampled = {
1114
+ 'name': 'content',
1115
+ 'pages': [],
1116
+ '_sample_pages': [1],
1117
+ 'rep': None,
1118
+ 'bg': None,
1119
+ 'slots': [{'role': 'title', 'sz': 48, 'txt': '首页标题'}],
1120
+ }
1121
+ template_only = {
1122
+ 'name': 'cover',
1123
+ 'pages': [],
1124
+ '_sample_pages': [],
1125
+ 'rep': None,
1126
+ 'bg': None,
1127
+ 'slots': [{'role': 'body', 'sz': 24, 'txt': '模板占位文本'}],
1128
+ }
1129
+
1130
+ with tempfile.TemporaryDirectory() as output_dir:
1131
+ draft.emit_brief(
1132
+ data,
1133
+ ([], [], [], {}, [], [], [], [sampled, template_only], [], [],
1134
+ [], [], [], None),
1135
+ output_dir,
1136
+ )
1137
+ with open(os.path.join(output_dir, 'BRIEF.md'), encoding='utf-8') as stream:
1138
+ brief = stream.read()
1139
+
1140
+ self.assertIn('| `content` | 1 | 1 |', brief)
1141
+ self.assertIn('第 1 页实际使用页型:`content`。若样张确为封面,只在 `roles.content` 填 `cover`',
1142
+ brief)
1143
+ self.assertIn('- `content`(第 1 页,覆盖 [1])', brief)
1144
+ self.assertIn('另有 1 个模板声明版式没有对应样张', brief)
1145
+ self.assertNotIn('模板占位文本', brief)
1146
+
1147
+ def test_unsampled_template_background_does_not_add_control_todos(self):
1148
+ sampled = {
1149
+ 'name': 'cover',
1150
+ 'zh': '封面',
1151
+ 'role': 'cover',
1152
+ 'pages': [1],
1153
+ 'rep': 1,
1154
+ 'bg': None,
1155
+ 'slots': [],
1156
+ 'decor': [],
1157
+ 'pic_n': 0,
1158
+ 'confidence': 'high',
1159
+ }
1160
+ template_only = {
1161
+ 'name': 'content',
1162
+ 'zh': '内容页',
1163
+ 'role': 'content',
1164
+ 'pages': [],
1165
+ 'rep': None,
1166
+ 'bg': 'bg-template-only',
1167
+ 'slots': [],
1168
+ 'decor': [],
1169
+ 'pic_n': 0,
1170
+ 'confidence': 'low',
1171
+ }
1172
+
1173
+ with tempfile.TemporaryDirectory() as output_dir:
1174
+ emit_layouts([sampled, template_only], output_dir)
1175
+ with open(os.path.join(output_dir, 'layout-controls.yaml'),
1176
+ encoding='utf-8') as stream:
1177
+ controls = stream.read()
1178
+
1179
+ background_block = controls.split('bg-template-only:', 1)[1]
1180
+ self.assertIn('text_safe: [0, 0, 0, 0]', background_block)
1181
+ self.assertIn('avoid: []', background_block)
1182
+ self.assertNotIn('TODO', background_block)
1183
+
1184
+ def test_form3_sample_page_background_adds_control_todos(self):
1185
+ sampled = {
1186
+ 'name': 'content',
1187
+ 'zh': '内容页',
1188
+ 'role': 'content',
1189
+ 'pages': [],
1190
+ '_sample_pages': [1],
1191
+ 'rep': None,
1192
+ 'bg': 'bg-sampled',
1193
+ 'slots': [],
1194
+ 'decor': [],
1195
+ 'pic_n': 0,
1196
+ 'confidence': 'high',
1197
+ }
1198
+
1199
+ with tempfile.TemporaryDirectory() as output_dir:
1200
+ emit_layouts([sampled], output_dir)
1201
+ with open(os.path.join(output_dir, 'layout-controls.yaml'),
1202
+ encoding='utf-8') as stream:
1203
+ controls = stream.read()
1204
+
1205
+ background_block = controls.split('bg-sampled:', 1)[1]
1206
+ self.assertIn('text_safe: TODO安全文字区', background_block)
1207
+ self.assertIn('avoid: TODO禁放区列表', background_block)
1208
+ self.assertIn('pairing_rule: "TODO', background_block)
1209
+
1210
+ def test_background_controls_only_edit_existing_image_backgrounds(self):
1211
+ archetype = {
1212
+ 'name': 'content',
1213
+ 'zh': '内容页',
1214
+ 'role': 'content',
1215
+ 'pages': [1],
1216
+ 'rep': 1,
1217
+ 'bg': 'bg-content',
1218
+ 'slots': [],
1219
+ 'decor': [],
1220
+ 'pic_n': 0,
1221
+ 'confidence': 'high',
1222
+ }
1223
+
1224
+ with tempfile.TemporaryDirectory() as output_dir:
1225
+ emit_layouts([archetype], output_dir)
1226
+ with open(os.path.join(output_dir, 'layout-controls.yaml'),
1227
+ encoding='utf-8') as stream:
1228
+ controls = stream.read()
1229
+
1230
+ self.assertIn('只编辑本草案已列出的真实图片背景', controls)
1231
+ self.assertIn('纯色、渐变、外框、几何装饰和透明叠层不新建 bg_rules', controls)
1232
+ self.assertIn('bg-content:', controls)
1233
+
1234
+ def test_template_layout_keeps_inherited_linear_gradient_background_as_decor(self):
1235
+ layout_part = 'ppt/slideLayouts/slideLayout1.xml'
1236
+ data = {
1237
+ 'canvas': {'px': [1920, 1080]},
1238
+ 'form_hint': {'form': 3},
1239
+ 'layouts': [{
1240
+ 'part': layout_part,
1241
+ 'name': '内容',
1242
+ 'used_by_slides': 1,
1243
+ 'background': {
1244
+ 'type': 'gradient',
1245
+ 'stops': [
1246
+ {'pos': 0, 'color': {'resolved': '#4B5CF5'}},
1247
+ {'pos': 100, 'color': {'resolved': '#233AFB'}},
1248
+ ],
1249
+ 'angle_deg': 0,
1250
+ },
1251
+ }, {
1252
+ 'part': 'ppt/slideLayouts/slideLayout2.xml',
1253
+ 'name': '目录',
1254
+ 'used_by_slides': 0,
1255
+ }, {
1256
+ 'part': 'ppt/slideLayouts/slideLayout3.xml',
1257
+ 'name': '封底',
1258
+ 'used_by_slides': 0,
1259
+ }],
1260
+ 'slides': [{
1261
+ 'part': 'ppt/slides/slide1.xml',
1262
+ 'layout': layout_part,
1263
+ 'background': None,
1264
+ }],
1265
+ 'images': [],
1266
+ 'media': [],
1267
+ 'theme_topology': {'themes': ['single'], 'per_master': [], 'default': 'single'},
1268
+ 'reference_graph': {
1269
+ 'master_of_layout': {},
1270
+ 'layout_of_slide': {'ppt/slides/slide1.xml': layout_part},
1271
+ },
1272
+ 'background_composites': {},
1273
+ }
1274
+ shapes = []
1275
+ for index, part in enumerate((layout_part,
1276
+ 'ppt/slideLayouts/slideLayout2.xml',
1277
+ 'ppt/slideLayouts/slideLayout3.xml'), 1):
1278
+ shapes.append({
1279
+ 'part': part,
1280
+ 'layer': 'layout',
1281
+ 'kind': 'sp',
1282
+ 'box': {'x': 100, 'y': 100, 'w': 600, 'h': 100},
1283
+ 'ph': {'type': 'title', 'idx': str(index)},
1284
+ 'text': {'paragraphs': [{'runs': [{'text': 'Title %d' % index,
1285
+ 'sz_px': 40}]}]},
1286
+ })
1287
+
1288
+ with tempfile.TemporaryDirectory() as output_dir:
1289
+ os.makedirs(os.path.join(output_dir, 'ref'))
1290
+ with open(os.path.join(output_dir, 'ref', 'shapes.json'), 'w',
1291
+ encoding='utf-8') as stream:
1292
+ import json
1293
+ json.dump({'shapes': shapes}, stream)
1294
+ archetypes, _, _ = draft_layouts(data, output_dir)
1295
+ emit_layouts(archetypes, output_dir)
1296
+ emit_manifest(data, [], [], output_dir, archetypes)
1297
+ with open(os.path.join(output_dir, 'layouts.yaml'), encoding='utf-8') as stream:
1298
+ layouts_yaml = stream.read()
1299
+ with open(os.path.join(output_dir, 'manifest.yaml'), encoding='utf-8') as stream:
1300
+ manifest = stream.read()
1301
+
1302
+ self.assertIn('box: [0, 0, 1920, 1080]', layouts_yaml)
1303
+ self.assertIn('background-image: linear-gradient(90deg, #4B5CF5 0%, #233AFB 100%)',
1304
+ layouts_yaml)
1305
+ self.assertIn('value: "[0, 0, 1920, 1080]"', manifest)
1306
+ self.assertIn('reason: "PPT 背景铺满画布"', manifest)
1307
+
1308
+ def test_sampled_layouts_group_inherited_structured_background(self):
1309
+ layout_part = 'ppt/slideLayouts/slideLayout1.xml'
1310
+ slides = [{
1311
+ 'part': 'ppt/slides/slide%d.xml' % index,
1312
+ 'layout': layout_part,
1313
+ 'background': None,
1314
+ } for index in range(1, 4)]
1315
+ data = {
1316
+ 'canvas': {'px': [1920, 1080]},
1317
+ 'form_hint': {'form': 1},
1318
+ 'layouts': [{
1319
+ 'part': layout_part,
1320
+ 'name': '内容',
1321
+ 'used_by_slides': 3,
1322
+ }],
1323
+ 'slides': slides,
1324
+ 'images': [],
1325
+ 'media': [],
1326
+ 'theme_topology': {'themes': ['single'], 'per_master': [], 'default': 'single'},
1327
+ 'reference_graph': {
1328
+ 'master_of_layout': {},
1329
+ 'layout_of_slide': {
1330
+ slide['part']: layout_part for slide in slides
1331
+ },
1332
+ },
1333
+ 'background_composites': {},
1334
+ }
1335
+ shapes = [{
1336
+ 'part': slide['part'],
1337
+ 'layer': 'slide',
1338
+ 'kind': 'sp',
1339
+ 'box': {'x': 100, 'y': 100, 'w': 600, 'h': 100},
1340
+ 'ph': {'type': 'title', 'idx': '1'},
1341
+ 'text': {'paragraphs': [{'runs': [{
1342
+ 'text': 'Title %d' % index,
1343
+ 'sz_px': 40,
1344
+ }]}]},
1345
+ } for index, slide in enumerate(slides, 1)]
1346
+
1347
+ backgrounds = [{
1348
+ 'source': 'bgPr',
1349
+ 'type': 'solid',
1350
+ 'color': {'resolved': '#FAF9F5'},
1351
+ }, {
1352
+ 'type': 'gradient',
1353
+ 'stops': [
1354
+ {'pos': 0, 'color': {'resolved': '#4B5CF5'}},
1355
+ {'pos': 100, 'color': {'resolved': '#233AFB'}},
1356
+ ],
1357
+ 'angle_deg': 0,
1358
+ }]
1359
+ for background in backgrounds:
1360
+ with self.subTest(background=background['type']):
1361
+ data['layouts'][0]['background'] = background
1362
+ with tempfile.TemporaryDirectory() as output_dir:
1363
+ os.makedirs(os.path.join(output_dir, 'ref'))
1364
+ with open(os.path.join(output_dir, 'ref', 'shapes.json'), 'w',
1365
+ encoding='utf-8') as stream:
1366
+ import json
1367
+ json.dump({'shapes': shapes}, stream)
1368
+ archetypes, pages, _ = draft_layouts(
1369
+ data, output_dir, effective_alpha={})
1370
+
1371
+ self.assertEqual([1, 2, 3], sorted(
1372
+ page for archetype in archetypes for page in archetype['pages']))
1373
+ self.assertTrue(all(page['background'] == background for page in pages))
1374
+ self.assertTrue(all(archetype['bg_raw'] is None
1375
+ for archetype in archetypes))
1376
+ self.assertTrue(all(
1377
+ any(decor.get('trace') == 'canvas-background'
1378
+ for decor in archetype['decor'])
1379
+ for archetype in archetypes
1380
+ ))
1381
+ self.assertTrue(all(
1382
+ all(isinstance(value, str)
1383
+ for value in archetype['_source_backgrounds'])
1384
+ for archetype in archetypes
1385
+ ))
1386
+
1387
+ def test_asset_vision_groups_fail_when_page_screenshot_is_unreadable(self):
1388
+ candidates = [{
1389
+ 'id': 'asset-1',
1390
+ 'file': 'ornament.png',
1391
+ 'out': 'media-out/ornament.png',
1392
+ 'probe': {'w': 80, 'h': 80},
1393
+ 'placements': [{'slide': 1, 'box': [10, 20, 80, 80]}],
1394
+ }]
1395
+
1396
+ with tempfile.TemporaryDirectory() as output_dir:
1397
+ png_dir = os.path.join(output_dir, 'ref', 'rebuild', 'png')
1398
+ lout_dir = os.path.join(output_dir, 'l-out')
1399
+ os.makedirs(png_dir)
1400
+ os.makedirs(lout_dir)
1401
+ with open(os.path.join(png_dir, 'slide-1.png'), 'w', encoding='utf-8') as stream:
1402
+ stream.write('not-a-png')
1403
+ with mock.patch.object(draft, 'render_asset_vision_pages', return_value=png_dir):
1404
+ with self.assertRaisesRegex(RuntimeError, '页面截图不可读取'):
1405
+ emit_asset_vision_groups(output_dir, candidates, 1, lout_dir)
1406
+
1407
+ def test_bound_visual_candidates_omit_unbound_media(self):
1408
+ bound = {
1409
+ 'file': 'corner-ornament.png',
1410
+ 'fullscreen': False,
1411
+ 'slides': [2],
1412
+ 'probe': {'alpha_mean': 255},
1413
+ }
1414
+ unbound = {
1415
+ 'file': 'unused-master.svg',
1416
+ 'fullscreen': False,
1417
+ 'slides': [],
1418
+ 'probe': {'alpha_mean': 255},
1419
+ }
1420
+
1421
+ archetypes = [{'slots': [{'source_media': 'corner-ornament.png'}]}]
1422
+
1423
+ self.assertEqual(
1424
+ bound_visual_candidates([bound, unbound], archetypes),
1425
+ [bound],
1426
+ )
1427
+
1428
+ def test_visual_slot_candidates_use_final_slot_geometry(self):
1429
+ candidate = {
1430
+ 'file': 'shared-illustration.png',
1431
+ 'fullscreen': False,
1432
+ 'probe': {'alpha_mean': 255},
1433
+ 'placements': [
1434
+ {'slide': 3, 'box': [1002, 285, 768, 647]},
1435
+ {'slide': 8, 'box': [114, 285, 768, 647]},
1436
+ ],
1437
+ }
1438
+ archetypes = [{
1439
+ 'name': 'section',
1440
+ 'pages': [8],
1441
+ 'rep': 8,
1442
+ 'slots': [{
1443
+ 'source_media': 'shared-illustration.png',
1444
+ 'box': [114, 285, 768, 647],
1445
+ }],
1446
+ }]
1447
+
1448
+ rows = visual_slot_candidates([candidate], archetypes)
1449
+
1450
+ self.assertEqual(rows[0]['placements'], [{
1451
+ 'slide': 8,
1452
+ 'box': [114, 285, 768, 647],
1453
+ 'archetype': 'section',
1454
+ }])
1455
+
1456
+ def test_first_page_background_is_not_automatically_a_cover(self):
1457
+ archetypes = [{
1458
+ 'name': 'layout-1',
1459
+ 'pages': [1],
1460
+ 'bg_raw': 'ppt/media/first-page.png',
1461
+ }]
1462
+
1463
+ self.assertIsNone(cover_background_media(archetypes))
1464
+ self.assertEqual(
1465
+ cover_background_media(archetypes + [{
1466
+ 'name': 'cover',
1467
+ 'pages': [],
1468
+ 'bg_raw': 'ppt/media/template-cover.png',
1469
+ }]),
1470
+ 'ppt/media/template-cover.png',
1471
+ )
1472
+
1473
+ def test_visual_slot_candidates_skip_unsampled_template_layouts(self):
1474
+ candidate = {
1475
+ 'file': 'template-ornament.png',
1476
+ 'fullscreen': False,
1477
+ 'probe': {'alpha_mean': 255},
1478
+ 'placements': [{'slide': 1, 'box': [10, 20, 80, 80]}],
1479
+ }
1480
+ archetypes = [{
1481
+ 'name': 'template-only',
1482
+ 'pages': [],
1483
+ '_sample_pages': [],
1484
+ 'rep': None,
1485
+ 'slots': [{
1486
+ 'source_media': 'template-ornament.png',
1487
+ 'box': [10, 20, 80, 80],
1488
+ }],
1489
+ }]
1490
+
1491
+ self.assertEqual(visual_slot_candidates([candidate], archetypes), [])
1492
+
1493
+ def test_visual_slot_candidates_skip_template_only_slot_without_page_context(self):
1494
+ candidate = {
1495
+ 'file': 'template-ornament.png',
1496
+ 'fullscreen': False,
1497
+ 'probe': {'alpha_mean': 255},
1498
+ 'placements': [{'layout': 'slideLayout-1', 'box': [10, 20, 80, 80]}],
1499
+ }
1500
+ archetypes = [{
1501
+ 'name': 'sampled-layout',
1502
+ 'pages': [1],
1503
+ 'rep': 1,
1504
+ 'slots': [{
1505
+ 'source_media': 'template-ornament.png',
1506
+ 'box': [10, 20, 80, 80],
1507
+ }],
1508
+ }]
1509
+
1510
+ self.assertEqual(visual_slot_candidates([candidate], archetypes), [])
1511
+
1512
+ def test_deterministic_fullscreen_images_skip_model_judgment(self):
1513
+ self.assertFalse(needs_asset_judgment({
1514
+ 'fullscreen': True,
1515
+ 'probe': {'alpha_mean': 255, 'near_blank': False},
1516
+ }))
1517
+ self.assertFalse(needs_asset_judgment({
1518
+ 'fullscreen': True,
1519
+ 'probe': {'alpha_mean': 5, 'near_blank': True},
1520
+ }))
1521
+ self.assertTrue(needs_asset_judgment({
1522
+ 'fullscreen': False,
1523
+ 'probe': {'alpha_mean': 5, 'near_blank': True},
1524
+ }))
1525
+
1526
+ def test_shape_fill_images_are_exposed_as_slide_marks(self):
1527
+ data = {
1528
+ 'images': [{
1529
+ 'media': 'ppt/media/fill.png',
1530
+ 'fullscreen': False,
1531
+ 'boxes': [{
1532
+ 'box': {'x': 120, 'y': 240, 'w': 360, 'h': 420},
1533
+ 'parts': ['ppt/slides/slide2.xml'],
1534
+ }],
1535
+ }],
1536
+ }
1537
+
1538
+ marks = slide_image_marks(data)
1539
+
1540
+ self.assertEqual(marks['ppt/slides/slide2.xml'], [{
1541
+ 'media': 'ppt/media/fill.png',
1542
+ 'box': {'x': 120, 'y': 240, 'w': 360, 'h': 420},
1543
+ }])
1544
+
1545
+ def test_form3_layouts_include_instance_image_fills(self):
1546
+ layouts = []
1547
+ shapes = []
1548
+ for index in range(1, 4):
1549
+ part = 'ppt/slideLayouts/slideLayout%d.xml' % index
1550
+ layouts.append({
1551
+ 'part': part,
1552
+ 'name': 'Layout %d' % index,
1553
+ 'used_by_slides': 1,
1554
+ })
1555
+ shapes.append({
1556
+ 'part': part,
1557
+ 'layer': 'layout',
1558
+ 'kind': 'sp',
1559
+ 'box': {'x': 10, 'y': 10, 'w': 500, 'h': 100},
1560
+ 'ph': {'type': 'title', 'idx': str(index)},
1561
+ 'text': {'paragraphs': [{'runs': [{
1562
+ 'text': 'Title %d' % index,
1563
+ 'sz_px': 40,
1564
+ }]}]},
1565
+ })
1566
+ data = {
1567
+ 'canvas': {'px': [1920, 1080]},
1568
+ 'form_hint': {'form': 3},
1569
+ 'layouts': layouts,
1570
+ 'images': [{
1571
+ 'media': 'ppt/media/fill.png',
1572
+ 'fullscreen': False,
1573
+ 'boxes': [{
1574
+ 'box': {'x': 120, 'y': 240, 'w': 360, 'h': 420},
1575
+ 'parts': ['ppt/slides/slide1.xml'],
1576
+ }],
1577
+ }],
1578
+ 'media': [],
1579
+ 'theme_topology': {
1580
+ 'themes': ['single'],
1581
+ 'per_master': [],
1582
+ 'default': 'single',
1583
+ },
1584
+ 'reference_graph': {
1585
+ 'master_of_layout': {},
1586
+ 'layout_of_slide': {
1587
+ 'ppt/slides/slide1.xml': 'ppt/slideLayouts/slideLayout1.xml',
1588
+ },
1589
+ },
1590
+ 'background_composites': {},
1591
+ }
1592
+
1593
+ with tempfile.TemporaryDirectory() as output_dir:
1594
+ os.makedirs(os.path.join(output_dir, 'ref'))
1595
+ with open(os.path.join(output_dir, 'ref', 'shapes.json'), 'w',
1596
+ encoding='utf-8') as stream:
1597
+ import json
1598
+ json.dump({'shapes': shapes}, stream)
1599
+ archetypes, _, _ = draft_layouts(data, output_dir)
1600
+
1601
+ self.assertTrue(any(
1602
+ slot.get('media') == 'ppt/media/fill.png'
1603
+ for archetype in archetypes
1604
+ for slot in archetype['slots']
1605
+ ))
1606
+
1607
+ def test_form3_layouts_include_translucent_fullscreen_overlay_as_candidate(self):
1608
+ layouts = []
1609
+ shapes = []
1610
+ for index in range(1, 4):
1611
+ part = 'ppt/slideLayouts/slideLayout%d.xml' % index
1612
+ layouts.append({
1613
+ 'part': part,
1614
+ 'name': 'Layout %d' % index,
1615
+ 'used_by_slides': 1,
1616
+ })
1617
+ shapes.append({
1618
+ 'part': part,
1619
+ 'layer': 'layout',
1620
+ 'kind': 'sp',
1621
+ 'box': {'x': 10, 'y': 10, 'w': 500, 'h': 100},
1622
+ 'ph': {'type': 'title', 'idx': str(index)},
1623
+ 'text': {'paragraphs': [{'runs': [{
1624
+ 'text': 'Title %d' % index,
1625
+ 'sz_px': 40,
1626
+ }]}]},
1627
+ })
1628
+ overlay_media = 'ppt/media/overlay.png'
1629
+ data = {
1630
+ 'canvas': {'px': [1920, 1080]},
1631
+ 'form_hint': {'form': 3},
1632
+ 'layouts': layouts,
1633
+ 'images': [{
1634
+ 'media': overlay_media,
1635
+ 'fullscreen': True,
1636
+ 'boxes': [{
1637
+ 'box': {'x': 0, 'y': 0, 'w': 1920, 'h': 1080},
1638
+ 'parts': ['ppt/slides/slide1.xml'],
1639
+ }],
1640
+ }],
1641
+ 'media': [],
1642
+ 'theme_topology': {
1643
+ 'themes': ['single'],
1644
+ 'per_master': [],
1645
+ 'default': 'single',
1646
+ },
1647
+ 'reference_graph': {
1648
+ 'master_of_layout': {},
1649
+ 'layout_of_slide': {
1650
+ 'ppt/slides/slide1.xml': 'ppt/slideLayouts/slideLayout1.xml',
1651
+ },
1652
+ },
1653
+ 'background_composites': {},
1654
+ }
1655
+
1656
+ with tempfile.TemporaryDirectory() as output_dir:
1657
+ os.makedirs(os.path.join(output_dir, 'ref'))
1658
+ with open(os.path.join(output_dir, 'ref', 'shapes.json'), 'w',
1659
+ encoding='utf-8') as stream:
1660
+ import json
1661
+ json.dump({'shapes': shapes}, stream)
1662
+ archetypes, _, _ = draft_layouts(
1663
+ data, output_dir, effective_alpha={overlay_media: 64})
1664
+
1665
+ self.assertTrue(any(
1666
+ slot.get('media') == overlay_media
1667
+ for archetype in archetypes
1668
+ for slot in archetype['slots']
1669
+ ))
1670
+
1671
+ def test_shape_opacity_marks_opaque_fullscreen_media_as_overlay(self):
1672
+ data = {
1673
+ 'media': [{
1674
+ 'media': 'ppt/media/overlay.png',
1675
+ 'out': 'media-out/overlay.png',
1676
+ }],
1677
+ 'images': [{
1678
+ 'media': 'ppt/media/overlay.png',
1679
+ 'fullscreen': True,
1680
+ }],
1681
+ }
1682
+ shapes = [{
1683
+ 'kind': 'pic',
1684
+ 'media': 'ppt/media/overlay.png',
1685
+ 'w_pct': 100,
1686
+ 'h_pct': 100,
1687
+ 'opacity': 0.25,
1688
+ }]
1689
+
1690
+ with tempfile.TemporaryDirectory() as output_dir:
1691
+ media_dir = os.path.join(output_dir, 'media-out')
1692
+ os.makedirs(media_dir)
1693
+ try:
1694
+ from PIL import Image
1695
+ except ImportError:
1696
+ self.skipTest('Pillow unavailable')
1697
+ Image.new('RGB', (16, 9), (20, 40, 60)).save(
1698
+ os.path.join(media_dir, 'overlay.png'))
1699
+
1700
+ overlays = fullscreen_overlay_media(data, output_dir, shapes)
1701
+
1702
+ self.assertEqual(overlays, {'ppt/media/overlay.png'})
1703
+
1704
+ def test_image_bearing_orphan_group_is_kept_without_count_threshold(self):
1705
+ kept = [('common', [{'no': 2, 'marks': []}])]
1706
+ ranked = kept + [
1707
+ ('one-image', [{'no': 5, 'marks': [{
1708
+ 'media': 'ppt/media/ornament.png',
1709
+ 'box': {'x': 40, 'y': 120, 'w': 80, 'h': 240},
1710
+ }]}]),
1711
+ ('no-image', [{'no': 6, 'marks': []}]),
1712
+ ]
1713
+
1714
+ selected = preserve_image_bearing_groups(kept, ranked)
1715
+
1716
+ self.assertEqual([group[0] for group in selected], ['common', 'one-image'])
1717
+
1718
+ def test_asset_vision_contexts_keep_same_source_per_layout_and_box(self):
1719
+ contexts = asset_vision_contexts([{
1720
+ 'id': 'asset-1',
1721
+ 'file': 'shared.png',
1722
+ 'placements': [
1723
+ {'slide': 2, 'archetype': 'layout-1', 'box': [10, 20, 80, 80]},
1724
+ {'slide': 3, 'archetype': 'layout-2', 'box': [10, 20, 80, 80]},
1725
+ {'slide': 4, 'archetype': 'layout-2', 'box': [10, 20, 80, 80]},
1726
+ {'slide': 4, 'archetype': 'layout-2', 'box': [100, 20, 80, 80]},
1727
+ ],
1728
+ }])
1729
+
1730
+ self.assertEqual(
1731
+ [(row['id'], row['layout'], row['placements'][0]['slide'])
1732
+ for row in contexts],
1733
+ [
1734
+ ('asset-1-s2', 'layout-1', 2),
1735
+ ('asset-1-s3', 'layout-2', 3),
1736
+ ('asset-1-s4', 'layout-2', 4),
1737
+ ],
1738
+ )
1739
+
1740
+ def test_contact_sheets_cover_all_candidates_without_truncation(self):
1741
+ try:
1742
+ from PIL import Image
1743
+ except ImportError:
1744
+ self.skipTest('Pillow unavailable')
1745
+
1746
+ with tempfile.TemporaryDirectory() as output_dir:
1747
+ media_dir = os.path.join(output_dir, 'media-out')
1748
+ lout_dir = os.path.join(output_dir, 'l-out')
1749
+ os.makedirs(media_dir)
1750
+ os.makedirs(lout_dir)
1751
+ candidates = []
1752
+ for index in range(13):
1753
+ filename = 'image-%02d.png' % index
1754
+ Image.new('RGBA', (8, 8), (index, 20, 30, 255)).save(
1755
+ os.path.join(media_dir, filename))
1756
+ candidates.append({
1757
+ 'file': filename,
1758
+ 'out': 'media-out/' + filename,
1759
+ 'n': 1,
1760
+ 'probe': {'w': 8, 'h': 8},
1761
+ })
1762
+
1763
+ sheets = contact_sheets(output_dir, candidates, lout_dir)
1764
+ legacy_exists = os.path.exists(os.path.join(lout_dir, 'contact-sheet.png'))
1765
+
1766
+ self.assertEqual(len(sheets), 2)
1767
+ self.assertEqual(
1768
+ [os.path.basename(path) for path in sheets],
1769
+ ['contact-sheet-1.png', 'contact-sheet-2.png'],
1770
+ )
1771
+ self.assertTrue(legacy_exists)
1772
+
1773
+ def test_content_candidates_at_same_geometry_collapse_to_one_slot(self):
1774
+ lines = resolve_asset_candidate_lines([
1775
+ ' - {role: asset-candidate, box: [10, 20, 300, 200], type: pic, '
1776
+ 'source_media: chart-a.png, css: "object-fit: contain"}',
1777
+ ' - {role: asset-candidate, box: [10, 20, 300, 200], type: pic, '
1778
+ 'source_media: chart-b.png}',
1779
+ ], {
1780
+ 'chart-a.png': 'content',
1781
+ 'chart-b.png': 'content',
1782
+ }, {})
1783
+
1784
+ self.assertEqual(lines, [
1785
+ ' - {role: pic, box: [10, 20, 300, 200], type: pic, css: "object-fit: contain"}',
1786
+ ])
1787
+
1788
+ def test_content_candidates_at_same_geometry_remain_in_distinct_layouts(self):
1789
+ lines = resolve_asset_candidate_lines([
1790
+ ' layout-a:',
1791
+ ' slots:',
1792
+ ' - {role: asset-candidate, box: [10, 20, 300, 200], type: pic, '
1793
+ 'source_media: chart-a.png}',
1794
+ ' layout-b:',
1795
+ ' slots:',
1796
+ ' - {role: asset-candidate, box: [10, 20, 300, 200], type: pic, '
1797
+ 'source_media: chart-b.png}',
1798
+ ], {
1799
+ 'chart-a.png': 'content',
1800
+ 'chart-b.png': 'content',
1801
+ }, {})
1802
+
1803
+ self.assertEqual(lines, [
1804
+ ' layout-a:',
1805
+ ' slots:',
1806
+ ' - {role: pic, box: [10, 20, 300, 200], type: pic}',
1807
+ ' layout-b:',
1808
+ ' slots:',
1809
+ ' - {role: pic, box: [10, 20, 300, 200], type: pic}',
1810
+ ])
1811
+
1812
+
1813
+ if __name__ == '__main__':
1814
+ unittest.main()