@lark-apaas/coding-steering 0.1.32-dev.4f80f68 → 0.1.32-dev.6ef2b6f

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 (19) hide show
  1. package/package.json +1 -1
  2. package/steering/design-html/skills/pptx-style-extract/SKILL.md +28 -19
  3. package/steering/design-html/skills/pptx-style-extract/scripts/census.py +8 -2
  4. package/steering/design-html/skills/pptx-style-extract/scripts/draft.py +1202 -191
  5. package/steering/design-html/skills/pptx-style-extract/scripts/extract.py +229 -10
  6. package/steering/design-html/skills/pptx-style-extract/scripts/ooxml.py +18 -1
  7. package/steering/design-html/skills/pptx-style-extract/scripts/package.py +643 -40
  8. package/steering/design-html/skills/pptx-style-extract/scripts/parts.py +19 -3
  9. package/steering/design-html/skills/pptx-style-extract/scripts/render_pages.py +4 -2
  10. package/steering/design-html/skills/pptx-style-extract/scripts/test_asset_judgment_package.py +556 -0
  11. package/steering/design-html/skills/pptx-style-extract/scripts/test_background_composite.py +308 -1
  12. package/steering/design-html/skills/pptx-style-extract/scripts/test_design_consumer_contract.py +14 -0
  13. package/steering/design-html/skills/pptx-style-extract/scripts/test_flow_layout_contract.py +157 -7
  14. package/steering/design-html/skills/pptx-style-extract/scripts/test_layout_css.py +1718 -2
  15. package/steering/design-html/skills/pptx-style-extract/scripts/test_logo_scope.py +600 -0
  16. package/steering/design-html/skills/pptx-style-extract/scripts/test_text_role_contract.py +151 -4
  17. package/steering/design-html/skills/pptx-style-extract/scripts/verify_layout_assets.py +421 -0
  18. package/steering/design-html/skills/pptx-style-extract/scripts/verify_logo_scope.py +12 -0
  19. package/steering/design-html/skills/pptx-style-extract/v2-format-spec.md +1 -1
@@ -4,15 +4,146 @@ import os
4
4
  import sys
5
5
  import tempfile
6
6
  import unittest
7
+ from unittest import mock
7
8
 
8
9
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
9
10
 
11
+ import draft # noqa: E402
10
12
  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
+ 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)
13
25
 
14
26
 
15
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
+
16
147
  def test_text_slot_emits_rendering_style_as_css_only(self):
17
148
  shape = {
18
149
  'text': {
@@ -62,8 +193,13 @@ class LayoutCssTest(unittest.TestCase):
62
193
  emit_layouts([archetype], output_dir)
63
194
  with open(os.path.join(output_dir, 'layouts.yaml'), encoding='utf-8') as stream:
64
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()
65
198
  layouts_md = build_layouts_md(split_top_blocks(layouts_yaml), (1920, 1080))
66
199
 
200
+ self.assertIn('names:', layout_controls)
201
+ self.assertNotIn('\nlayouts:\n', layout_controls)
202
+ self.assertNotIn('box: [100, 80, 800, 160]', layout_controls)
67
203
  self.assertIn(
68
204
  'css: "box-sizing: border-box; padding: 8px 10px 6px 12px; '
69
205
  'font-size: 48px; font-weight: 600; font-style: italic; '
@@ -93,6 +229,1586 @@ class LayoutCssTest(unittest.TestCase):
93
229
  self.assertEqual(len(result.fails), 1)
94
230
  self.assertIn('旧样式键 align/size', result.fails[0])
95
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
+
96
1812
 
97
1813
  if __name__ == '__main__':
98
1814
  unittest.main()