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

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 +1175 -189
  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 +1598 -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
@@ -0,0 +1,301 @@
1
+ #!/usr/bin/env python3
2
+ """Regression tests for template asset placement in generated deck HTML."""
3
+ import os
4
+ import subprocess
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 verify_layout_assets import validate_layout_assets
12
+
13
+
14
+ class LayoutAssetContractTest(unittest.TestCase):
15
+ def write_pack(self, root):
16
+ with open(os.path.join(root, 'design.md'), 'w', encoding='utf-8') as stream:
17
+ stream.write(
18
+ '---\n'
19
+ 'assets:\n'
20
+ ' logo-1:\n'
21
+ ' path: assets/logos/1.png\n'
22
+ ' kind: logo\n'
23
+ 'layouts: layouts.md\n'
24
+ '---\n'
25
+ )
26
+ with open(os.path.join(root, 'layouts.md'), 'w', encoding='utf-8') as stream:
27
+ stream.write(
28
+ '---\n'
29
+ 'canvas: 1920x1080\n'
30
+ 'layouts:\n'
31
+ ' cover:\n'
32
+ ' role: cover\n'
33
+ ' slots:\n'
34
+ ' - {role: logo, box: [64, 64, 224, 48], type: pic, asset: logo-1}\n'
35
+ ' content:\n'
36
+ ' role: content\n'
37
+ ' slots:\n'
38
+ ' - {role: title, box: [100, 100, 800, 100], type: title}\n'
39
+ ' closing:\n'
40
+ ' role: closing\n'
41
+ ' slots:\n'
42
+ ' - {role: logo, box: [64, 64, 224, 48], type: pic, asset: logo-1}\n'
43
+ '---\n'
44
+ )
45
+
46
+ def write_html(self, root, content):
47
+ path = os.path.join(root, 'index.html')
48
+ with open(path, 'w', encoding='utf-8') as stream:
49
+ stream.write(content)
50
+ return path
51
+
52
+ def write_texture_pack(self, root):
53
+ with open(os.path.join(root, 'design.md'), 'w', encoding='utf-8') as stream:
54
+ stream.write(
55
+ '---\n'
56
+ 'assets:\n'
57
+ ' texture-1:\n'
58
+ ' path: assets/textures/1.webp\n'
59
+ ' kind: texture\n'
60
+ 'layouts: layouts.md\n'
61
+ '---\n'
62
+ )
63
+ with open(os.path.join(root, 'layouts.md'), 'w', encoding='utf-8') as stream:
64
+ stream.write(
65
+ '---\n'
66
+ 'canvas: 1920x1080\n'
67
+ 'layouts:\n'
68
+ ' cover:\n'
69
+ ' role: cover\n'
70
+ ' slots:\n'
71
+ ' - {role: texture, box: [1142, 0, 778, 1080], type: pic, asset: texture-1}\n'
72
+ ' content:\n'
73
+ ' role: content\n'
74
+ ' slots:\n'
75
+ ' - {role: title, box: [100, 100, 800, 100], type: title}\n'
76
+ '---\n'
77
+ )
78
+
79
+ def write_background_pack(self, root):
80
+ with open(os.path.join(root, 'design.md'), 'w', encoding='utf-8') as stream:
81
+ stream.write(
82
+ '---\n'
83
+ 'assets:\n'
84
+ ' bg-content-1:\n'
85
+ ' path: assets/backgrounds/content.webp\n'
86
+ ' kind: background\n'
87
+ 'layouts: layouts.md\n'
88
+ '---\n'
89
+ )
90
+ with open(os.path.join(root, 'layouts.md'), 'w', encoding='utf-8') as stream:
91
+ stream.write(
92
+ '---\n'
93
+ 'canvas: 1920x1080\n'
94
+ 'layouts:\n'
95
+ ' content:\n'
96
+ ' role: content\n'
97
+ ' background: bg-content-1\n'
98
+ ' slots:\n'
99
+ ' - {role: title, box: [100, 100, 800, 100], type: title}\n'
100
+ '---\n'
101
+ )
102
+
103
+ def test_logo_is_limited_to_every_archetype_with_its_slot(self):
104
+ with tempfile.TemporaryDirectory() as root:
105
+ self.write_pack(root)
106
+ html = self.write_html(
107
+ root,
108
+ '<deck-stage>'
109
+ '<section data-pptx-layout="cover">'
110
+ '<img src="assets/pptx-volcengine/logos/1.png"></section>'
111
+ '<section data-pptx-layout="content"><h1>正文</h1></section>'
112
+ '<section data-pptx-layout="closing">'
113
+ '<img src="assets/pptx-volcengine/logos/1.png"></section>'
114
+ '</deck-stage>',
115
+ )
116
+
117
+ self.assertEqual(
118
+ [],
119
+ validate_layout_assets(root, html, 'assets/pptx-volcengine'),
120
+ )
121
+
122
+ def test_logo_on_an_unowned_archetype_is_rejected(self):
123
+ with tempfile.TemporaryDirectory() as root:
124
+ self.write_pack(root)
125
+ html = self.write_html(
126
+ root,
127
+ '<deck-stage>'
128
+ '<section data-pptx-layout="content">'
129
+ '<img src="assets/pptx-volcengine/logos/1.png"></section>'
130
+ '</deck-stage>',
131
+ )
132
+
133
+ problems = validate_layout_assets(root, html, 'assets/pptx-volcengine')
134
+
135
+ self.assertEqual(1, len(problems))
136
+ self.assertIn('content', problems[0])
137
+ self.assertIn('logo-1', problems[0])
138
+
139
+ def test_nested_content_section_does_not_become_a_slide(self):
140
+ with tempfile.TemporaryDirectory() as root:
141
+ self.write_pack(root)
142
+ html = self.write_html(
143
+ root,
144
+ '<deck-stage><section data-pptx-layout="cover">'
145
+ '<section class="content"><img src="assets/pptx-volcengine/logos/1.png">'
146
+ '</section></section></deck-stage>',
147
+ )
148
+
149
+ self.assertEqual(
150
+ [],
151
+ validate_layout_assets(root, html, 'assets/pptx-volcengine'),
152
+ )
153
+
154
+ def test_each_slide_declares_its_template_archetype(self):
155
+ with tempfile.TemporaryDirectory() as root:
156
+ self.write_pack(root)
157
+ html = self.write_html(
158
+ root,
159
+ '<deck-stage><section><h1>未声明页型</h1></section></deck-stage>',
160
+ )
161
+
162
+ self.assertEqual(
163
+ ['第 1 页缺少 data-pptx-layout,无法核验模板资产归属'],
164
+ validate_layout_assets(root, html, 'assets/pptx-volcengine'),
165
+ )
166
+
167
+ def test_logo_in_global_css_is_rejected(self):
168
+ with tempfile.TemporaryDirectory() as root:
169
+ self.write_pack(root)
170
+ html = self.write_html(
171
+ root,
172
+ '<style>.slide-logo { background-image: url('
173
+ '"assets/pptx-volcengine/logos/1.png") }</style>'
174
+ '<deck-stage><section data-pptx-layout="content">正文</section></deck-stage>',
175
+ )
176
+
177
+ self.assertEqual(
178
+ ['模板资产 logo-1 出现在 slide section 外,无法核验页型归属'],
179
+ validate_layout_assets(root, html, 'assets/pptx-volcengine'),
180
+ )
181
+
182
+ def test_logo_in_a_slide_style_block_is_rejected(self):
183
+ with tempfile.TemporaryDirectory() as root:
184
+ self.write_pack(root)
185
+ html = self.write_html(
186
+ root,
187
+ '<deck-stage><section data-pptx-layout="content"><style>'
188
+ '.slide-logo { background-image: url('
189
+ '"assets/pptx-volcengine/logos/1.png") }</style>'
190
+ '正文</section></deck-stage>',
191
+ )
192
+
193
+ self.assertEqual(
194
+ ['模板资产 logo-1 出现在 slide section 外,无法核验页型归属'],
195
+ validate_layout_assets(root, html, 'assets/pptx-volcengine'),
196
+ )
197
+
198
+ def test_layout_must_use_its_bound_texture(self):
199
+ with tempfile.TemporaryDirectory() as root:
200
+ self.write_texture_pack(root)
201
+ html = self.write_html(
202
+ root,
203
+ '<deck-stage><section data-pptx-layout="cover">'
204
+ '<img src="assets/generated/replacement.webp"></section></deck-stage>',
205
+ )
206
+
207
+ problems = validate_layout_assets(root, html, 'assets/pptx-claude')
208
+
209
+ self.assertEqual(1, len(problems))
210
+ self.assertIn('cover', problems[0])
211
+ self.assertIn('texture-1', problems[0])
212
+ self.assertIn('必须使用', problems[0])
213
+
214
+ def test_layout_bound_texture_on_an_unowned_layout_is_rejected(self):
215
+ with tempfile.TemporaryDirectory() as root:
216
+ self.write_texture_pack(root)
217
+ html = self.write_html(
218
+ root,
219
+ '<deck-stage><section data-pptx-layout="content">'
220
+ '<img src="assets/pptx-claude/textures/1.webp"></section></deck-stage>',
221
+ )
222
+
223
+ problems = validate_layout_assets(root, html, 'assets/pptx-claude')
224
+
225
+ self.assertEqual(1, len(problems))
226
+ self.assertIn('content', problems[0])
227
+ self.assertIn('texture-1', problems[0])
228
+ self.assertIn('不得使用', problems[0])
229
+
230
+ def test_layout_must_use_its_bound_background_with_query_and_fragment(self):
231
+ with tempfile.TemporaryDirectory() as root:
232
+ self.write_background_pack(root)
233
+ html = self.write_html(
234
+ root,
235
+ '<deck-stage><section data-pptx-layout="content" '
236
+ 'style="background-image:url('
237
+ '\'assets/pptx-claude/backgrounds/content.webp?v=2#slide\')">'
238
+ '正文</section></deck-stage>',
239
+ )
240
+
241
+ self.assertEqual(
242
+ [],
243
+ validate_layout_assets(root, html, 'assets/pptx-claude'),
244
+ )
245
+
246
+ def test_repeated_asset_slots_require_repeated_placements(self):
247
+ with tempfile.TemporaryDirectory() as root:
248
+ self.write_texture_pack(root)
249
+ with open(os.path.join(root, 'layouts.md'), 'w', encoding='utf-8') as stream:
250
+ stream.write(
251
+ '---\n'
252
+ 'canvas: 1920x1080\n'
253
+ 'layouts:\n'
254
+ ' cover:\n'
255
+ ' role: cover\n'
256
+ ' slots:\n'
257
+ ' - {role: texture, box: [0, 0, 100, 100], type: pic, asset: texture-1}\n'
258
+ ' - {role: texture, box: [1800, 980, 100, 100], type: pic, asset: texture-1}\n'
259
+ '---\n'
260
+ )
261
+ html = self.write_html(
262
+ root,
263
+ '<deck-stage><section data-pptx-layout="cover">'
264
+ '<img src="assets/pptx-claude/textures/1.webp"></section></deck-stage>',
265
+ )
266
+
267
+ problems = validate_layout_assets(root, html, 'assets/pptx-claude')
268
+
269
+ self.assertEqual(1, len(problems))
270
+ self.assertIn('共 2 处', problems[0])
271
+ self.assertIn('缺少 1 处', problems[0])
272
+
273
+ def test_legacy_logo_scope_entrypoint_keeps_failure_exit_code(self):
274
+ with tempfile.TemporaryDirectory() as root:
275
+ self.write_texture_pack(root)
276
+ html = self.write_html(
277
+ root,
278
+ '<deck-stage><section data-pptx-layout="cover">'
279
+ '<img src="assets/generated/replacement.webp"></section></deck-stage>',
280
+ )
281
+
282
+ result = subprocess.run(
283
+ [
284
+ sys.executable,
285
+ os.path.join(os.path.dirname(__file__), 'verify_logo_scope.py'),
286
+ root,
287
+ html,
288
+ '--asset-prefix',
289
+ 'assets/pptx-claude',
290
+ ],
291
+ capture_output=True,
292
+ check=False,
293
+ text=True,
294
+ )
295
+
296
+ self.assertEqual(1, result.returncode)
297
+ self.assertIn('PPTX_LAYOUT_ASSETS: FAIL', result.stdout)
298
+
299
+
300
+ if __name__ == '__main__':
301
+ unittest.main()
@@ -2,6 +2,7 @@
2
2
  """Regression tests for inherited layout text and model-decided text roles."""
3
3
  import json
4
4
  import os
5
+ import re
5
6
  import sys
6
7
  import tempfile
7
8
  import unittest
@@ -142,16 +143,16 @@ class TextRoleContractTest(unittest.TestCase):
142
143
  with open(path, encoding='utf-8') as stream:
143
144
  draft = stream.read()
144
145
 
145
- self.assertIn('text_roles:', draft)
146
+ self.assertIn('默认均为 body', draft)
146
147
  self.assertIn(
147
- 'layout-1-text-1: TODO文本角色',
148
+ '# text-role: layout-1-text-1',
148
149
  draft,
149
150
  )
150
151
  self.assertEqual(draft.count('box: [120, 80, 840, 120]'), 1)
151
152
 
152
153
  decided = draft.replace(
153
- 'layout-1-text-1: TODO文本角色',
154
- 'layout-1-text-1: title',
154
+ 'layouts:',
155
+ 'text_roles:\n layout-1-text-1: title\nlayouts:',
155
156
  )
156
157
  layouts_md = build_layouts_md(split_top_blocks(decided), (1920, 1080))
157
158
 
@@ -163,6 +164,152 @@ class TextRoleContractTest(unittest.TestCase):
163
164
  )
164
165
  self.assertIn('css: "font-size: 48px; color: #C41230"', layouts_md)
165
166
 
167
+ def test_header_role_uses_a_v2_slot_type(self):
168
+ draft = """names:
169
+ layout-1: 内容页
170
+ roles:
171
+ layout-1: content
172
+ text_roles:
173
+ layout-1-text-1: header
174
+ layouts:
175
+ layout-1:
176
+ slots:
177
+ # text-role: layout-1-text-1
178
+ - {role: body, box: [120, 80, 840, 120], type: body}
179
+ confidence: high
180
+ """
181
+
182
+ layouts_md = build_layouts_md(split_top_blocks(draft), (1920, 1080))
183
+
184
+ self.assertIn(
185
+ 'role: header, box: [120, 80, 840, 120], type: body',
186
+ layouts_md,
187
+ )
188
+ self.assertNotIn('type: header', layouts_md)
189
+
190
+ def test_decided_layout_role_replaces_the_draft_default(self):
191
+ draft = """names:
192
+ layout-1: 末页
193
+ roles:
194
+ layout-1: closing
195
+ layouts:
196
+ layout-1:
197
+ role: content
198
+ slots:
199
+ - {role: title, box: [120, 80, 840, 120], type: title}
200
+ confidence: high
201
+ """
202
+
203
+ layouts_md = build_layouts_md(split_top_blocks(draft), (1920, 1080))
204
+
205
+ self.assertIn(' role: closing', layouts_md)
206
+ self.assertNotIn(' role: content', layouts_md)
207
+ self.assertEqual(layouts_md.count(' role:'), 1)
208
+
209
+ def test_last_slide_is_kept_as_a_role_candidate_when_layout_limit_is_full(self):
210
+ shapes, slides = [], []
211
+ for index in range(1, 11):
212
+ slide_part = 'ppt/slides/slide%d.xml' % index
213
+ shapes.append(text_shape(
214
+ slide_part,
215
+ 'slide',
216
+ str(index),
217
+ {'x': 120, 'y': 80, 'w': 840, 'h': 120},
218
+ 'Slide %d' % index,
219
+ {'type': 'body', 'idx': str(index)},
220
+ ))
221
+ slides.append({
222
+ 'part': slide_part,
223
+ 'layout': 'ppt/slideLayouts/slideLayout1.xml',
224
+ 'background': '#%02x0000' % index,
225
+ })
226
+ data = {
227
+ 'canvas': {'px': [1920, 1080]},
228
+ 'form_hint': {'form': 0},
229
+ 'slides': slides,
230
+ 'background_composites': {},
231
+ }
232
+
233
+ with tempfile.TemporaryDirectory() as output_dir:
234
+ os.makedirs(os.path.join(output_dir, 'ref'))
235
+ with open(os.path.join(output_dir, 'ref', 'shapes.json'), 'w',
236
+ encoding='utf-8') as stream:
237
+ json.dump({'shapes': shapes}, stream)
238
+ archetypes, _, leftover = draft_layouts(data, output_dir)
239
+ emit_layouts(archetypes, output_dir)
240
+ with open(os.path.join(output_dir, 'layouts.yaml'), encoding='utf-8') as stream:
241
+ draft = stream.read()
242
+
243
+ last_archetype = next(
244
+ archetype for archetype in archetypes if archetype['pages'] == [10]
245
+ )
246
+ self.assertTrue(last_archetype['_last_page_candidate'])
247
+ self.assertNotIn(10, leftover)
248
+ self.assertIn(
249
+ '代表页 %s,共 1 页;' % last_archetype['rep'],
250
+ draft,
251
+ )
252
+ self.assertIn('末页候选,结合样张判断 closing 或实际角色', draft)
253
+ decided = draft.replace(
254
+ '%s: TODO角色' % last_archetype['name'],
255
+ '%s: closing' % last_archetype['name'],
256
+ )
257
+ layouts_md = build_layouts_md(split_top_blocks(decided), (1920, 1080))
258
+ self.assertRegex(
259
+ layouts_md,
260
+ r' %s:\n(?: .*\n)*? role: closing\n'
261
+ % last_archetype['name'],
262
+ )
263
+ self.assertRegex(
264
+ layouts_md,
265
+ r' %s:\n(?: .*\n)*? - \{role: body, box: \[120, 80, 840, 120\]'
266
+ % last_archetype['name'],
267
+ )
268
+ self.assertIn(
269
+ ' %s:' % last_archetype['name'],
270
+ layouts_md,
271
+ )
272
+
273
+ def test_template_layout_shape_without_ph_key_does_not_crash(self):
274
+ # form=3 模板里,版式层可能有「带 box、带文字、但没有 ph 键」的普通形状
275
+ # (非占位符的文本/装饰)。layouts_from_template 的入口筛选是
276
+ # `s.get('ph') or shape_text(s)`——有文字就放进来,随后按 ph 判类型时若用
277
+ # s['ph'] 直接下标就会 KeyError: 'ph',整份抽取在草案阶段崩掉(EXTRACT_PARTIAL)。
278
+ layout_part = 'ppt/slideLayouts/slideLayout1.xml'
279
+ shape = {
280
+ 'part': layout_part,
281
+ 'layer': 'layout',
282
+ 'id': '7',
283
+ 'kind': 'sp',
284
+ 'name': '页脚文字',
285
+ # 关键:没有 'ph' 键
286
+ 'box': {'x': 100, 'y': 980, 'w': 800, 'h': 60},
287
+ 'text': {
288
+ 'bodyPr': {},
289
+ 'lstStyle': {'lvl1pPr': {'sz_px': 20}},
290
+ 'paragraphs': [{'runs': [{'text': '内部资料'}]}],
291
+ },
292
+ }
293
+ data = {
294
+ 'canvas': {'px': [1920, 1080]},
295
+ 'form_hint': {'form': 3},
296
+ 'layouts': [{'part': layout_part}],
297
+ 'slides': [{'part': 'ppt/slides/slide1.xml', 'layout': layout_part,
298
+ 'background': None}],
299
+ 'background_composites': {},
300
+ }
301
+
302
+ with tempfile.TemporaryDirectory() as output_dir:
303
+ os.makedirs(os.path.join(output_dir, 'ref'))
304
+ with open(os.path.join(output_dir, 'ref', 'shapes.json'), 'w',
305
+ encoding='utf-8') as stream:
306
+ json.dump({'shapes': [shape]}, stream)
307
+ # 修复前这里抛 KeyError: 'ph'(draft.py 用 s['ph'] 直接下标),
308
+ # 抽取在草案阶段崩掉、退成 EXTRACT_PARTIAL。修复后应正常返回。
309
+ archetypes, pages, leftover = draft_layouts(data, output_dir)
310
+
311
+ self.assertIsInstance(archetypes, list)
312
+
166
313
 
167
314
  if __name__ == '__main__':
168
315
  unittest.main()
@@ -0,0 +1,206 @@
1
+ #!/usr/bin/env python3
2
+ """Verify that generated slides honor every asset bound to their PPTX layout."""
3
+ import argparse
4
+ import posixpath
5
+ import re
6
+ import sys
7
+ from collections import Counter
8
+ from html.parser import HTMLParser
9
+ from urllib.parse import urlsplit
10
+
11
+ from check_v2 import Pack
12
+
13
+
14
+ URL_RE = re.compile(r'url\(\s*[\'"]?([^\'")\s]+)', re.I)
15
+ VOID_TAGS = {
16
+ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
17
+ 'link', 'meta', 'param', 'source', 'track', 'wbr',
18
+ }
19
+
20
+
21
+ def normalized_path(value):
22
+ path = urlsplit(value).path.replace('\\', '/')
23
+ return posixpath.normpath(path).lstrip('./')
24
+
25
+
26
+ def bound_asset_ids(value, known_asset_ids):
27
+ found = Counter()
28
+ if isinstance(value, dict):
29
+ for key, child in value.items():
30
+ if (key in ('asset', 'background')
31
+ and isinstance(child, str)
32
+ and child in known_asset_ids):
33
+ found[child] += 1
34
+ found.update(bound_asset_ids(child, known_asset_ids))
35
+ elif isinstance(value, list):
36
+ for child in value:
37
+ found.update(bound_asset_ids(child, known_asset_ids))
38
+ return found
39
+
40
+
41
+ def layout_asset_contract(pack):
42
+ known_asset_ids = set(pack.assets)
43
+ required = {}
44
+ owners = {}
45
+ for layout_name, (layout, _) in pack.layouts.items():
46
+ asset_counts = bound_asset_ids(layout, known_asset_ids)
47
+ required[layout_name] = asset_counts
48
+ for asset_id in asset_counts:
49
+ owners.setdefault(asset_id, set()).add(layout_name)
50
+ return required, owners
51
+
52
+
53
+ def asset_urls(pack, asset_prefix, asset_ids):
54
+ prefix = normalized_path(asset_prefix).rstrip('/')
55
+ urls = {}
56
+ for asset_id in asset_ids:
57
+ entry, _ = pack.assets.get(asset_id, (None, None))
58
+ if not isinstance(entry, dict):
59
+ continue
60
+ path = entry.get('path')
61
+ if not isinstance(path, str) or not path:
62
+ continue
63
+ relative = normalized_path(path)
64
+ if relative.startswith('assets/'):
65
+ relative = relative[len('assets/'):]
66
+ urls[posixpath.join(prefix, relative)] = asset_id
67
+ return urls
68
+
69
+
70
+ def urls_from_attrs(attrs):
71
+ urls = []
72
+ for key, value in attrs:
73
+ if not value:
74
+ continue
75
+ if key.lower() in ('src', 'href'):
76
+ urls.append(value)
77
+ elif key.lower() == 'srcset':
78
+ urls.extend(item.strip().split(' ', 1)[0] for item in value.split(','))
79
+ elif key.lower() == 'style':
80
+ urls.extend(URL_RE.findall(value))
81
+ return urls
82
+
83
+
84
+ class SlideAssetParser(HTMLParser):
85
+ def __init__(self):
86
+ super().__init__()
87
+ self.slides = []
88
+ self._tags = []
89
+ self._sections = []
90
+ self._active_slides = []
91
+ self._style_depth = 0
92
+ self.outside_urls = []
93
+
94
+ def handle_starttag(self, tag, attrs):
95
+ tag = tag.lower()
96
+ urls = urls_from_attrs(attrs)
97
+ parent = self._tags[-1] if self._tags else None
98
+ if tag == 'section':
99
+ slide = (
100
+ {'layout': dict(attrs).get('data-pptx-layout'), 'urls': urls}
101
+ if parent == 'deck-stage' else None
102
+ )
103
+ self._sections.append(slide)
104
+ if slide is not None:
105
+ self._active_slides.append(slide)
106
+ elif self._active_slides:
107
+ self._active_slides[-1]['urls'].extend(urls)
108
+ else:
109
+ self.outside_urls.extend(urls)
110
+ if tag == 'style':
111
+ self._style_depth += 1
112
+ if tag not in VOID_TAGS:
113
+ self._tags.append(tag)
114
+
115
+ def handle_startendtag(self, tag, attrs):
116
+ if self._active_slides:
117
+ self._active_slides[-1]['urls'].extend(urls_from_attrs(attrs))
118
+ else:
119
+ self.outside_urls.extend(urls_from_attrs(attrs))
120
+
121
+ def handle_endtag(self, tag):
122
+ tag = tag.lower()
123
+ if tag == 'style' and self._style_depth:
124
+ self._style_depth -= 1
125
+ if tag == 'section' and self._sections:
126
+ slide = self._sections.pop()
127
+ if slide is not None:
128
+ self.slides.append(slide)
129
+ self._active_slides.pop()
130
+ if tag not in VOID_TAGS and self._tags:
131
+ self._tags.pop()
132
+
133
+ def handle_data(self, data):
134
+ if not self._style_depth:
135
+ return
136
+ self.outside_urls.extend(URL_RE.findall(data))
137
+
138
+
139
+ def validate_layout_assets(pack_dir, html_path, asset_prefix):
140
+ """Return violations of the asset contract declared by each layout."""
141
+ pack = Pack(pack_dir)
142
+ required, owners = layout_asset_contract(pack)
143
+ known_urls = asset_urls(pack, asset_prefix, owners)
144
+ if not known_urls:
145
+ return []
146
+
147
+ with open(html_path, encoding='utf-8') as stream:
148
+ text = stream.read()
149
+ parser = SlideAssetParser()
150
+ parser.feed(text)
151
+ parser.close()
152
+
153
+ problems = []
154
+ for url in parser.outside_urls:
155
+ asset_id = known_urls.get(normalized_path(url))
156
+ if asset_id:
157
+ problems.append(
158
+ '模板资产 %s 出现在 slide section 外,无法核验页型归属' % asset_id)
159
+ for number, slide in enumerate(parser.slides, 1):
160
+ layout = slide['layout']
161
+ if not layout:
162
+ problems.append('第 %d 页缺少 data-pptx-layout,无法核验模板资产归属' % number)
163
+ continue
164
+ if layout not in pack.layouts:
165
+ problems.append('第 %d 页声明了不存在的模板页型: %s' % (number, layout))
166
+ continue
167
+ used_asset_counts = Counter(
168
+ known_urls[normalized_path(url)]
169
+ for url in slide['urls']
170
+ if normalized_path(url) in known_urls
171
+ )
172
+ for asset_id in sorted(used_asset_counts):
173
+ if layout not in owners.get(asset_id, set()):
174
+ allowed = '、'.join(sorted(owners.get(asset_id) or ())) or '(无)'
175
+ problems.append(
176
+ '第 %d 页页型 %s 不得使用 %s;只允许: %s'
177
+ % (number, layout, asset_id, allowed))
178
+ for asset_id, required_count in sorted(required.get(layout, {}).items()):
179
+ missing_count = required_count - used_asset_counts[asset_id]
180
+ if missing_count > 0:
181
+ problems.append(
182
+ '第 %d 页页型 %s 必须使用其绑定资产 %s 共 %d 处,当前缺少 %d 处'
183
+ % (number, layout, asset_id, required_count, missing_count))
184
+ return problems
185
+
186
+
187
+ def main(argv=None):
188
+ parser = argparse.ArgumentParser(
189
+ description='Verify that generated deck HTML honors PPTX layout asset bindings.')
190
+ parser.add_argument('pack_dir')
191
+ parser.add_argument('html_path')
192
+ parser.add_argument('--asset-prefix', required=True)
193
+ args = parser.parse_args(argv)
194
+
195
+ problems = validate_layout_assets(args.pack_dir, args.html_path, args.asset_prefix)
196
+ if not problems:
197
+ print('PPTX_LAYOUT_ASSETS: PASS')
198
+ return 0
199
+ print('PPTX_LAYOUT_ASSETS: FAIL count=%d' % len(problems))
200
+ for problem in problems:
201
+ print('[layoutAssets] %s' % problem)
202
+ return 1
203
+
204
+
205
+ if __name__ == '__main__':
206
+ sys.exit(main())