@lark-apaas/coding-steering 0.1.32-dev.a87aa13 → 0.1.32-dev.f6e7ce8

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