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

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 +1153 -179
  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 +7 -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 +1519 -2
  15. package/steering/design-html/skills/pptx-style-extract/scripts/test_logo_scope.py +301 -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 +206 -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
@@ -299,6 +299,22 @@ NV_TAGS = {'sp': 'p:nvSpPr', 'pic': 'p:nvPicPr', 'grpSp': 'p:nvGrpSpPr',
299
299
  'cxnSp': 'p:nvCxnSpPr', 'graphicFrame': 'p:nvGraphicFramePr'}
300
300
 
301
301
 
302
+ def blip_opacity(blip):
303
+ """Combine the alpha modulation transforms attached to one local picture."""
304
+ opacity = 1.0
305
+ found = False
306
+ for effect in blip:
307
+ if local(effect.tag) not in ('alphaMod', 'alphaModFix'):
308
+ continue
309
+ try:
310
+ amount = int(effect.get('amt'))
311
+ except (TypeError, ValueError):
312
+ continue
313
+ opacity *= max(0.0, min(amount / 100000.0, 1.0))
314
+ found = True
315
+ return round(opacity, 6) if found else None
316
+
317
+
302
318
  def walk_tree(el, ctx, out, path=(), xf=(1.0, 1.0, 0.0, 0.0), depth=0):
303
319
  sx, sy, dx, dy = xf
304
320
  U, W, H = ctx.units, ctx.units.w, ctx.units.h
@@ -393,9 +409,9 @@ def walk_tree(el, ctx, out, path=(), xf=(1.0, 1.0, 0.0, 0.0), depth=0):
393
409
  blip = sp.find('p:blipFill/a:blip', NS)
394
410
  if blip is not None:
395
411
  rec['media'] = ctx.media_of(blip.get(R_EMBED)) or ctx.media_of(blip.get(R_LINK))
396
- alpha = blip.find('a:alphaModFix', NS)
397
- if alpha is not None and alpha.get('amt') is not None:
398
- rec['opacity'] = round(int(alpha.get('amt')) / 100000.0, 6)
412
+ opacity = blip_opacity(blip)
413
+ if opacity is not None:
414
+ rec['opacity'] = opacity
399
415
  svg = blip.find('a:extLst//asvg:svgBlip', NS)
400
416
  if svg is not None:
401
417
  rec['media_svg'] = ctx.media_of(svg.get(R_EMBED))
@@ -88,13 +88,15 @@ def css_gradient(grad):
88
88
  def background_css(bg):
89
89
  """页底 <p:bg> 描述符 → CSS background 值。解析不出返回 None,交上层回退到下一层底色。
90
90
 
91
- bgPr solid / bgRef 都带 `color`;gradient 与形状渐变共用 css_gradient;
92
- 背景图不重建(媒体未必导出),按无底色处理。
91
+ bgPr solid / bgRef 都带 `color`;线性 gradient 与形状渐变共用 css_gradient;
92
+ path 渐变和背景图不重建,按无底色处理,不能伪装成线性渐变。
93
93
  """
94
94
  if not bg:
95
95
  return None
96
96
  kind = bg.get('type')
97
97
  if kind == 'gradient':
98
+ if bg.get('path'):
99
+ return None
98
100
  return css_gradient(bg)
99
101
  if kind in ('image', 'none', 'pattern'):
100
102
  return None
@@ -0,0 +1,556 @@
1
+ #!/usr/bin/env python3
2
+ """Integration regression tests for model-decided image roles."""
3
+ import json
4
+ import os
5
+ import sys
6
+ import tempfile
7
+ import unittest
8
+
9
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
10
+
11
+ from check_v2 import Pack, RULES # noqa: E402
12
+ from package import (Fail, apply_asset_decisions, bind_asset_vision_group_scopes,
13
+ load_layout_blocks, main as package_main, # noqa: E402
14
+ validate_asset_vision_groups)
15
+
16
+
17
+ class AssetJudgmentPackageTest(unittest.TestCase):
18
+ def test_asset_vision_index_requires_selected_candidates(self):
19
+ with tempfile.TemporaryDirectory() as lout:
20
+ with open(os.path.join(lout, 'asset-vision-groups.json'),
21
+ 'w', encoding='utf-8') as stream:
22
+ json.dump({'version': 2}, stream)
23
+
24
+ with self.assertRaisesRegex(Fail, '缺少 selected'):
25
+ validate_asset_vision_groups(lout, {'asset_vision_groups': []})
26
+
27
+ def test_asset_vision_groups_must_keep_every_extracted_candidate(self):
28
+ with tempfile.TemporaryDirectory() as lout:
29
+ with open(os.path.join(lout, 'asset-vision-groups.json'),
30
+ 'w', encoding='utf-8') as stream:
31
+ json.dump({
32
+ 'version': 2,
33
+ 'selected': [{
34
+ 'candidates': [{
35
+ 'id': 'asset-cover',
36
+ 'source_media': 'cover.png',
37
+ 'placements': [{
38
+ 'box': [100, 200, 300, 400],
39
+ 'archetype': 'cover',
40
+ }],
41
+ }, {
42
+ 'id': 'asset-ornament',
43
+ 'source_media': 'ornament.png',
44
+ 'placements': [{
45
+ 'box': [10, 20, 30, 40],
46
+ 'archetype': 'content',
47
+ }],
48
+ }],
49
+ }],
50
+ }, stream)
51
+ manifest = {
52
+ 'asset_vision_groups': [{
53
+ 'id': 'asset-cover',
54
+ 'source_media': 'cover.png',
55
+ 'layout': 'cover',
56
+ 'box': '[100, 200, 300, 400]',
57
+ 'visual_kind': 'illustration',
58
+ }, {
59
+ 'id': 'asset-ornament',
60
+ 'source_media': 'ornament.png',
61
+ 'layout': 'content',
62
+ 'box': '[10, 20, 30, 40]',
63
+ 'visual_kind': 'decorative',
64
+ }],
65
+ }
66
+
67
+ validate_asset_vision_groups(lout, manifest)
68
+ manifest['asset_vision_groups'].pop()
69
+
70
+ with self.assertRaisesRegex(Fail, '逐项保留抽取候选.*asset-ornament'):
71
+ validate_asset_vision_groups(lout, manifest)
72
+
73
+ def test_asset_vision_groups_reject_rewritten_extracted_candidate_scope(self):
74
+ with tempfile.TemporaryDirectory() as lout:
75
+ with open(os.path.join(lout, 'asset-vision-groups.json'),
76
+ 'w', encoding='utf-8') as stream:
77
+ json.dump({
78
+ 'version': 2,
79
+ 'selected': [{
80
+ 'candidates': [{
81
+ 'id': 'asset-ornament',
82
+ 'source_media': 'ornament.png',
83
+ 'placements': [{
84
+ 'box': [10, 20, 30, 40],
85
+ 'archetype': None,
86
+ }],
87
+ }],
88
+ }],
89
+ }, stream)
90
+ manifest = {
91
+ 'asset_vision_groups': [{
92
+ 'id': 'asset-ornament',
93
+ 'source_media': 'other.png',
94
+ 'box': '[10, 20, 30, 40]',
95
+ 'visual_kind': 'decorative',
96
+ }],
97
+ }
98
+
99
+ with self.assertRaisesRegex(Fail, '改写了抽取候选的图片或位置'):
100
+ validate_asset_vision_groups(lout, manifest)
101
+
102
+ def test_v2_candidate_ids_resolve_same_box_on_different_layouts(self):
103
+ with tempfile.TemporaryDirectory() as lout:
104
+ with open(os.path.join(lout, 'asset-vision-groups.json'),
105
+ 'w', encoding='utf-8') as stream:
106
+ json.dump({
107
+ 'version': 2,
108
+ 'selected': [{
109
+ 'candidates': [{
110
+ 'id': 'asset-cover-logo',
111
+ 'source_media': 'shared.png',
112
+ 'placements': [{
113
+ 'box': [100, 200, 80, 80],
114
+ 'archetype': 'cover',
115
+ }],
116
+ }, {
117
+ 'id': 'asset-content-ornament',
118
+ 'source_media': 'shared.png',
119
+ 'placements': [{
120
+ 'box': [100, 200, 80, 80],
121
+ 'archetype': 'content',
122
+ }],
123
+ }],
124
+ }],
125
+ }, stream)
126
+ manifest = {
127
+ 'assets': [],
128
+ 'asset_vision_groups': [{
129
+ 'id': 'asset-cover-logo',
130
+ 'source_media': 'shared.png',
131
+ 'box': '[100, 200, 80, 80]',
132
+ 'visual_kind': 'logo',
133
+ }, {
134
+ 'id': 'asset-content-ornament',
135
+ 'source_media': 'shared.png',
136
+ 'box': '[100, 200, 80, 80]',
137
+ 'visual_kind': 'decorative',
138
+ }],
139
+ }
140
+
141
+ bind_asset_vision_group_scopes(lout, manifest)
142
+ decisions, _ = apply_asset_decisions(
143
+ manifest,
144
+ bound_sources={'shared.png'},
145
+ bound_slots={
146
+ 'shared.png': {
147
+ ('cover', (100, 200, 80, 80)),
148
+ ('content', (100, 200, 80, 80)),
149
+ },
150
+ },
151
+ )
152
+
153
+ self.assertEqual(
154
+ decisions.for_scope('shared.png', 'cover', (100, 200, 80, 80)),
155
+ 'logo',
156
+ )
157
+ self.assertEqual(
158
+ decisions.for_scope('shared.png', 'content', (100, 200, 80, 80)),
159
+ 'texture',
160
+ )
161
+
162
+ def test_legacy_asset_vision_index_accepts_a_group_default(self):
163
+ with tempfile.TemporaryDirectory() as lout:
164
+ with open(os.path.join(lout, 'asset-vision-groups.json'),
165
+ 'w', encoding='utf-8') as stream:
166
+ json.dump({
167
+ 'version': 1,
168
+ 'selected': [{
169
+ 'candidates': [{
170
+ 'id': 'asset-cover',
171
+ 'source_media': 'cover.png',
172
+ 'placements': [{'box': [0, 0, 100, 100]}],
173
+ }, {
174
+ 'id': 'asset-ornament',
175
+ 'source_media': 'ornament.png',
176
+ 'placements': [{'box': [10, 20, 30, 40]}],
177
+ }],
178
+ }],
179
+ }, stream)
180
+ manifest = {
181
+ 'asset_vision_groups': [{
182
+ 'id': 'vision-1',
183
+ 'source_media': '[cover.png, ornament.png]',
184
+ 'visual_kind': 'decorative',
185
+ }],
186
+ }
187
+
188
+ validate_asset_vision_groups(lout, manifest)
189
+
190
+ def test_layout_controls_override_legacy_judgment_without_reading_layout_todos(self):
191
+ with tempfile.TemporaryDirectory() as root:
192
+ lout = os.path.join(root, 'l-out')
193
+ os.makedirs(lout)
194
+ with open(os.path.join(lout, 'layouts.yaml'), 'w', encoding='utf-8') as stream:
195
+ stream.write(
196
+ 'names:\n'
197
+ ' content: TODO中文名\n'
198
+ 'roles:\n'
199
+ ' content: TODO角色\n'
200
+ 'layouts:\n'
201
+ ' content:\n'
202
+ ' slots:\n'
203
+ ' - {role: body, box: [10, 20, 30, 40], type: body}\n'
204
+ ' confidence: high\n'
205
+ )
206
+ with open(os.path.join(lout, 'layout-controls.yaml'), 'w', encoding='utf-8') as stream:
207
+ stream.write(
208
+ 'names:\n'
209
+ ' content: "内容页"\n'
210
+ 'roles:\n'
211
+ ' content: content\n'
212
+ )
213
+
214
+ blocks = load_layout_blocks(lout)
215
+
216
+ self.assertEqual([key for key, _, _ in blocks],
217
+ ['names', 'roles', 'layouts'])
218
+ self.assertIn('内容页', blocks[0][2][0])
219
+ self.assertIn('content', blocks[1][2][0])
220
+ self.assertNotIn('TODO', '\n'.join(
221
+ line for _, _, lines in blocks for line in lines
222
+ ))
223
+
224
+ def test_layout_controls_reject_non_judgment_block(self):
225
+ with tempfile.TemporaryDirectory() as root:
226
+ lout = os.path.join(root, 'l-out')
227
+ os.makedirs(lout)
228
+ with open(os.path.join(lout, 'layouts.yaml'), 'w', encoding='utf-8') as stream:
229
+ stream.write('layouts:\n content:\n role: content\n')
230
+ with open(os.path.join(lout, 'layout-controls.yaml'), 'w', encoding='utf-8') as stream:
231
+ stream.write('layouts:\n cover:\n role: cover\n')
232
+
233
+ with self.assertRaisesRegex(Fail, '只能包含'):
234
+ load_layout_blocks(lout)
235
+
236
+ def test_layout_controls_must_cover_all_legacy_judgment_blocks(self):
237
+ with tempfile.TemporaryDirectory() as root:
238
+ lout = os.path.join(root, 'l-out')
239
+ os.makedirs(lout)
240
+ with open(os.path.join(lout, 'layouts.yaml'), 'w', encoding='utf-8') as stream:
241
+ stream.write(
242
+ 'names:\n'
243
+ ' content: "内容页"\n'
244
+ 'roles:\n'
245
+ ' content: content\n'
246
+ 'layouts:\n'
247
+ ' content:\n'
248
+ ' role: content\n'
249
+ )
250
+ with open(os.path.join(lout, 'layout-controls.yaml'), 'w',
251
+ encoding='utf-8') as stream:
252
+ stream.write('roles:\n content: content\n')
253
+
254
+ with self.assertRaisesRegex(Fail, '缺:names'):
255
+ load_layout_blocks(lout)
256
+
257
+ def test_layout_controls_must_keep_each_legacy_judgment_entry(self):
258
+ with tempfile.TemporaryDirectory() as root:
259
+ lout = os.path.join(root, 'l-out')
260
+ os.makedirs(lout)
261
+ with open(os.path.join(lout, 'layouts.yaml'), 'w', encoding='utf-8') as stream:
262
+ stream.write(
263
+ 'names:\n'
264
+ ' cover: "封面"\n'
265
+ ' content: "内容页"\n'
266
+ 'roles:\n'
267
+ ' cover: cover\n'
268
+ ' content: content\n'
269
+ 'layouts:\n'
270
+ ' cover:\n'
271
+ ' role: cover\n'
272
+ ' content:\n'
273
+ ' role: content\n'
274
+ )
275
+ with open(os.path.join(lout, 'layout-controls.yaml'), 'w',
276
+ encoding='utf-8') as stream:
277
+ stream.write(
278
+ 'names:\n'
279
+ ' cover: "封面"\n'
280
+ 'roles:\n'
281
+ ' cover: cover\n'
282
+ ' content: content\n'
283
+ )
284
+
285
+ with self.assertRaisesRegex(Fail, 'names 段缺少旧判断条目:content'):
286
+ load_layout_blocks(lout)
287
+
288
+ def test_package_strips_judgment_fields_and_preserves_content_slot(self):
289
+ try:
290
+ from PIL import Image
291
+ except ImportError:
292
+ self.skipTest('Pillow unavailable')
293
+
294
+ with tempfile.TemporaryDirectory() as root:
295
+ stage1 = os.path.join(root, 'stage1')
296
+ lout = os.path.join(stage1, 'l-out')
297
+ pack = os.path.join(root, 'pack')
298
+ media = os.path.join(stage1, 'media-out')
299
+ ref = os.path.join(stage1, 'ref')
300
+ os.makedirs(lout)
301
+ os.makedirs(media)
302
+ os.makedirs(ref)
303
+
304
+ Image.new('RGBA', (40, 20), (20, 40, 60, 255)).save(
305
+ os.path.join(media, 'partner-logo.png'))
306
+ Image.new('RGBA', (24, 24), (60, 40, 20, 255)).save(
307
+ os.path.join(media, 'ornament.png'))
308
+
309
+ box_rows = [
310
+ ('ppt/media/image1.png', 'partner-logo.png', [100, 200, 400, 200]),
311
+ ('ppt/media/image2.png', 'ornament.png', [40, 40, 120, 120]),
312
+ ]
313
+ extract = {
314
+ 'source': {'filename': 'fixture.pptx'},
315
+ 'canvas': {
316
+ 'px': [1920, 1080],
317
+ 'source': {'cx': 12192000, 'cy': 6858000, 'unit': 'EMU'},
318
+ },
319
+ 'media': [
320
+ {
321
+ 'media': source,
322
+ 'out': 'media-out/' + output,
323
+ 'source_px': [40, 20] if output == 'partner-logo.png' else [24, 24],
324
+ }
325
+ for source, output, _ in box_rows
326
+ ],
327
+ 'images': [
328
+ {
329
+ 'media': source,
330
+ 'boxes': [{
331
+ 'count': 1,
332
+ 'box': {'x': box[0], 'y': box[1], 'w': box[2], 'h': box[3]},
333
+ 'parts': ['ppt/slides/slide1.xml'],
334
+ }],
335
+ }
336
+ for source, _, box in box_rows
337
+ ],
338
+ 'color_freq': [],
339
+ 'text_scale': [],
340
+ }
341
+ with open(os.path.join(stage1, 'extract.json'), 'w', encoding='utf-8') as stream:
342
+ json.dump(extract, stream)
343
+ with open(os.path.join(ref, 'shapes.json'), 'w', encoding='utf-8') as stream:
344
+ json.dump({
345
+ 'shapes': [
346
+ {
347
+ 'part': 'ppt/slides/slide1.xml',
348
+ 'kind': 'pic',
349
+ 'name': output,
350
+ 'box': {'x': box[0], 'y': box[1], 'w': box[2], 'h': box[3]},
351
+ }
352
+ for _, output, box in box_rows
353
+ ],
354
+ }, stream)
355
+
356
+ with open(os.path.join(lout, 'manifest.yaml'), 'w', encoding='utf-8') as stream:
357
+ stream.write(
358
+ 'version: alpha\n'
359
+ 'name: asset-judgment-fixture\n'
360
+ 'description: >\n'
361
+ ' A restrained fixture with explicit image-role judgments and traced layout slots.\n'
362
+ 'assets:\n'
363
+ ' - id: logo-primary\n'
364
+ ' source_media: partner-logo.png\n'
365
+ ' kind: logo\n'
366
+ ' on-bg: light\n'
367
+ 'asset_vision_groups:\n'
368
+ ' - id: vision-1\n'
369
+ ' source_media: [partner-logo.png, ornament.png]\n'
370
+ ' visual_kind: content-image\n'
371
+ 'asset_decisions:\n'
372
+ ' - source_media: ornament.png\n'
373
+ ' visual_kind: decorative\n'
374
+ )
375
+ with open(os.path.join(lout, 'frontmatter.yaml'), 'w', encoding='utf-8') as stream:
376
+ stream.write('')
377
+ with open(os.path.join(lout, 'layouts.yaml'), 'w', encoding='utf-8') as stream:
378
+ stream.write(
379
+ 'roles:\n'
380
+ ' content: TODO角色\n'
381
+ 'layouts:\n'
382
+ ' content:\n'
383
+ ' name: "内容页"\n'
384
+ ' role: content\n'
385
+ ' slots:\n'
386
+ ' - {role: logo, box: [100, 200, 400, 200], type: pic, '
387
+ 'asset: logo-primary, source_media: partner-logo.png}\n'
388
+ ' - {role: asset-candidate, box: [40, 40, 120, 120], type: pic, '
389
+ 'source_media: ornament.png}\n'
390
+ ' confidence: high\n'
391
+ )
392
+ with open(os.path.join(lout, 'layout-controls.yaml'), 'w',
393
+ encoding='utf-8') as stream:
394
+ stream.write('roles:\n content: content\n')
395
+ with open(os.path.join(lout, 'body.md'), 'w', encoding='utf-8') as stream:
396
+ stream.write(
397
+ '## Usage\n\n'
398
+ 'Read `layouts.md` before composing a slide.\n\n'
399
+ '## Assets\n\n'
400
+ '{{ASSET_TABLE}}\n\n'
401
+ '## Hard Rules\n\n'
402
+ '{{LOGO_RULES}}\n'
403
+ )
404
+ check_v1 = os.path.join(root, 'check_v1.py')
405
+ with open(check_v1, 'w', encoding='utf-8') as stream:
406
+ stream.write('raise SystemExit(0)\n')
407
+
408
+ rc = package_main([stage1, lout, pack, '--check-v1', check_v1])
409
+
410
+ self.assertEqual(rc, 0)
411
+ with open(os.path.join(pack, 'design.md'), encoding='utf-8') as stream:
412
+ design = stream.read()
413
+ with open(os.path.join(pack, 'layouts.md'), encoding='utf-8') as stream:
414
+ layouts = stream.read()
415
+ self.assertNotIn('asset_decisions', design)
416
+ self.assertNotIn('asset_vision_groups', design)
417
+ self.assertNotIn('visual_kind', design)
418
+ self.assertNotIn('source_media', design)
419
+ self.assertNotIn('source_media', layouts)
420
+ self.assertNotIn('TODO', layouts)
421
+ self.assertNotIn('{{LOGO_RULES}}', design)
422
+ self.assertNotIn('logo-primary', design)
423
+ self.assertNotIn('logo-primary', layouts)
424
+ self.assertIn(
425
+ '- {role: pic, box: [100, 200, 400, 200], type: pic}',
426
+ layouts,
427
+ )
428
+ self.assertIn('texture-1:', design)
429
+ self.assertIn('kind: texture', design)
430
+ self.assertIn(
431
+ '- {role: texture, box: [40, 40, 120, 120], type: pic, asset: texture-1}',
432
+ layouts,
433
+ )
434
+ gate = Pack(pack)
435
+ self.assertFalse([
436
+ result
437
+ for result in (rule(gate) for rule in RULES)
438
+ if result.fails
439
+ ])
440
+
441
+ def test_package_places_a_rendered_picture_instance_as_a_logo_asset(self):
442
+ try:
443
+ from PIL import Image
444
+ except ImportError:
445
+ self.skipTest('Pillow unavailable')
446
+
447
+ with tempfile.TemporaryDirectory() as root:
448
+ stage1 = os.path.join(root, 'stage1')
449
+ lout = os.path.join(stage1, 'l-out')
450
+ pack = os.path.join(root, 'pack')
451
+ media = os.path.join(stage1, 'media-out')
452
+ ref = os.path.join(stage1, 'ref')
453
+ os.makedirs(lout)
454
+ os.makedirs(media)
455
+ os.makedirs(ref)
456
+
457
+ source = 'generated/instance/picture-directory-logo.webp'
458
+ output = 'picture-directory-logo.webp'
459
+ box = [1813, 18, 59, 228]
460
+ Image.new('RGBA', (59, 228), (20, 60, 100, 255)).save(
461
+ os.path.join(media, output), 'WEBP')
462
+ extract = {
463
+ 'source': {'filename': 'fixture.pptx'},
464
+ 'canvas': {
465
+ 'px': [1920, 1080],
466
+ 'source': {'cx': 12192000, 'cy': 6858000, 'unit': 'EMU'},
467
+ },
468
+ 'media': [{
469
+ 'media': source,
470
+ 'out': 'media-out/' + output,
471
+ 'generated': True,
472
+ 'rendered_from': 'ppt/media/original-logo.png',
473
+ }],
474
+ 'images': [{
475
+ 'media': source,
476
+ 'boxes': [{
477
+ 'count': 1,
478
+ 'box': dict(zip(('x', 'y', 'w', 'h'), box)),
479
+ 'parts': ['ppt/slides/slide2.xml'],
480
+ }],
481
+ }],
482
+ 'color_freq': [],
483
+ 'text_scale': [],
484
+ }
485
+ with open(os.path.join(stage1, 'extract.json'), 'w', encoding='utf-8') as stream:
486
+ json.dump(extract, stream)
487
+ with open(os.path.join(ref, 'shapes.json'), 'w', encoding='utf-8') as stream:
488
+ json.dump({'shapes': [{
489
+ 'part': 'ppt/slides/slide2.xml',
490
+ 'kind': 'pic',
491
+ 'box': dict(zip(('x', 'y', 'w', 'h'), box)),
492
+ }]}, stream)
493
+ with open(os.path.join(lout, 'asset-vision-groups.json'),
494
+ 'w', encoding='utf-8') as stream:
495
+ json.dump({
496
+ 'version': 2,
497
+ 'selected': [{
498
+ 'candidates': [{
499
+ 'id': 'asset-directory-logo',
500
+ 'source_media': output,
501
+ 'placements': [{
502
+ 'slide': 2,
503
+ 'archetype': 'content',
504
+ 'box': box,
505
+ }],
506
+ }],
507
+ }],
508
+ 'omitted': [],
509
+ }, stream)
510
+ with open(os.path.join(lout, 'manifest.yaml'), 'w', encoding='utf-8') as stream:
511
+ stream.write(
512
+ 'version: alpha\n'
513
+ 'name: rendered-instance-fixture\n'
514
+ 'description: Rendered local PPT image instances remain reusable assets.\n'
515
+ 'asset_vision_groups:\n'
516
+ ' - id: asset-directory-logo\n'
517
+ ' source_media: picture-directory-logo.webp\n'
518
+ ' box: [1813, 18, 59, 228]\n'
519
+ ' visual_kind: logo\n'
520
+ )
521
+ for name, content in (
522
+ ('frontmatter.yaml', ''),
523
+ ('body.md', 'Read `layouts.md` before composing a slide.\n\n{{ASSET_TABLE}}\n'),
524
+ ('layouts.yaml',
525
+ 'layouts:\n'
526
+ ' content:\n'
527
+ ' name: "目录"\n'
528
+ ' role: content\n'
529
+ ' slots:\n'
530
+ ' - {role: asset-candidate, box: [1813, 18, 59, 228], type: pic, '
531
+ 'source_media: picture-directory-logo.webp}\n'
532
+ ' confidence: high\n'),
533
+ ):
534
+ with open(os.path.join(lout, name), 'w', encoding='utf-8') as stream:
535
+ stream.write(content)
536
+ check_v1 = os.path.join(root, 'check_v1.py')
537
+ with open(check_v1, 'w', encoding='utf-8') as stream:
538
+ stream.write('raise SystemExit(0)\n')
539
+
540
+ self.assertEqual(package_main([stage1, lout, pack, '--check-v1', check_v1]), 0)
541
+
542
+ with open(os.path.join(pack, 'design.md'), encoding='utf-8') as stream:
543
+ design = stream.read()
544
+ with open(os.path.join(pack, 'layouts.md'), encoding='utf-8') as stream:
545
+ layouts = stream.read()
546
+ self.assertTrue(os.path.isfile(os.path.join(pack, 'assets', 'logos', '1.webp')))
547
+ self.assertIn('logo-1:', design)
548
+ self.assertIn('assets/logos/1.webp', design)
549
+ self.assertIn(
550
+ '- {role: logo, box: [1813, 18, 59, 228], type: pic, asset: logo-1}',
551
+ layouts,
552
+ )
553
+
554
+
555
+ if __name__ == '__main__':
556
+ unittest.main()