@lark-apaas/coding-steering 0.1.32-dev.f3c32d8 → 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.
- package/package.json +1 -1
- package/steering/design-html/skills/pptx-style-extract/SKILL.md +1 -1
- package/steering/design-html/skills/pptx-style-extract/scripts/draft.py +7 -2
- package/steering/design-html/skills/pptx-style-extract/scripts/test_design_consumer_contract.py +7 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/test_logo_scope.py +187 -9
- package/steering/design-html/skills/pptx-style-extract/scripts/verify_layout_assets.py +240 -46
package/package.json
CHANGED
|
@@ -142,7 +142,7 @@ When generating a deck:
|
|
|
142
142
|
PYTHONDONTWRITEBYTECODE=1 python3 -B scripts/verify_layout_assets.py <pack_dir> <index.html> --asset-prefix <copied-assets-prefix>
|
|
143
143
|
```
|
|
144
144
|
|
|
145
|
-
`PPTX_LAYOUT_ASSETS: FAIL` means
|
|
145
|
+
`PPTX_LAYOUT_ASSETS: FAIL` means fix every reported instance according to `layouts.md`, then rerun the same command. Do not deliver or call `run_commit` until it prints `PPTX_LAYOUT_ASSETS: PASS`.
|
|
146
146
|
9. Run the slide preflight checks: no resource failures, no section overflow, and sampled screenshots follow the package colors, typography, layouts, assets, and Hard Rules.
|
|
147
147
|
|
|
148
148
|
## Export Consumer Attachments
|
|
@@ -3075,8 +3075,13 @@ def emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, l
|
|
|
3075
3075
|
'`[x, y, w, h]`(%dx%d 画布上的绝对像素),机械展开成 `left/top/width/height`;'
|
|
3076
3076
|
'slot 的 `css` 是模板排版属性已转译好的声明串,原样写进 style,不要另选字号、'
|
|
3077
3077
|
'内边距、颜色或对齐。'
|
|
3078
|
-
'带 `asset` 的 slot
|
|
3079
|
-
'
|
|
3078
|
+
'带 `asset` 的 slot 是固定图片实例:元素写 `data-pptx-asset="<asset id>"`,'
|
|
3079
|
+
'引用复制后的原资产,并把 `box` 直接写成 inline '
|
|
3080
|
+
'`position:absolute;left:<x>px;top:<y>px;width:<w>px;height:<h>px`。'
|
|
3081
|
+
'元素必须可见,不得省略或换图,也不得隐藏或只在 CSS 里伪装引用;'
|
|
3082
|
+
'这个页型没有 `asset` 槽,这一页就不出现该资产。'
|
|
3083
|
+
'页型的 `background` 是图片资产时遵循同一实例契约,`box` 使用全画布 '
|
|
3084
|
+
'`[0, 0, %d, %d]`。' % (canvas[0], canvas[1], canvas[0], canvas[1]),
|
|
3080
3085
|
'5. **铺装饰几何** —— 页型的 `decor` 是这一页的图形骨架(图标托底的圆、'
|
|
3081
3086
|
'卡片、分隔线):每条渲染成一个绝对定位空元素,`box` 给位置,`css` 逐项原样写进 '
|
|
3082
3087
|
'style;没有 `border-radius` 就按 `0`。只有 `geom: ellipse` 另加 '
|
package/steering/design-html/skills/pptx-style-extract/scripts/test_design_consumer_contract.py
CHANGED
|
@@ -61,6 +61,13 @@ class DesignConsumerContractTest(unittest.TestCase):
|
|
|
61
61
|
self.assertIn('确认全部页型后再开始搭页', body)
|
|
62
62
|
self.assertIn('不能只看摘要、前几个页型', body)
|
|
63
63
|
self.assertIn('data-pptx-layout="<页型名>"', body)
|
|
64
|
+
self.assertIn('data-pptx-asset="<asset id>"', body)
|
|
65
|
+
self.assertIn(
|
|
66
|
+
'position:absolute;left:<x>px;top:<y>px;width:<w>px;height:<h>px',
|
|
67
|
+
body,
|
|
68
|
+
)
|
|
69
|
+
self.assertIn('元素必须可见', body)
|
|
70
|
+
self.assertIn('`box` 使用全画布 `[0, 0, 1920, 1080]`', body)
|
|
64
71
|
self.assertIn('该页型绑定的背景与图片资产均已使用', body)
|
|
65
72
|
self.assertIn('不得省略或换图', body)
|
|
66
73
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""Regression tests for template asset placement in generated deck HTML."""
|
|
3
3
|
import os
|
|
4
|
+
import shutil
|
|
4
5
|
import subprocess
|
|
5
6
|
import sys
|
|
6
7
|
import tempfile
|
|
@@ -12,7 +13,22 @@ from verify_layout_assets import validate_layout_assets
|
|
|
12
13
|
|
|
13
14
|
|
|
14
15
|
class LayoutAssetContractTest(unittest.TestCase):
|
|
16
|
+
def write_asset(self, root, relative_path, contents, copied_prefix):
|
|
17
|
+
source = os.path.join(root, 'assets', relative_path)
|
|
18
|
+
copied = os.path.join(root, copied_prefix, relative_path)
|
|
19
|
+
os.makedirs(os.path.dirname(source), exist_ok=True)
|
|
20
|
+
os.makedirs(os.path.dirname(copied), exist_ok=True)
|
|
21
|
+
with open(source, 'wb') as stream:
|
|
22
|
+
stream.write(contents)
|
|
23
|
+
shutil.copyfile(source, copied)
|
|
24
|
+
|
|
15
25
|
def write_pack(self, root):
|
|
26
|
+
self.write_asset(
|
|
27
|
+
root,
|
|
28
|
+
'logos/1.png',
|
|
29
|
+
b'logo',
|
|
30
|
+
'assets/pptx-volcengine',
|
|
31
|
+
)
|
|
16
32
|
with open(os.path.join(root, 'design.md'), 'w', encoding='utf-8') as stream:
|
|
17
33
|
stream.write(
|
|
18
34
|
'---\n'
|
|
@@ -50,6 +66,12 @@ class LayoutAssetContractTest(unittest.TestCase):
|
|
|
50
66
|
return path
|
|
51
67
|
|
|
52
68
|
def write_texture_pack(self, root):
|
|
69
|
+
self.write_asset(
|
|
70
|
+
root,
|
|
71
|
+
'textures/1.webp',
|
|
72
|
+
b'texture',
|
|
73
|
+
'assets/pptx-claude',
|
|
74
|
+
)
|
|
53
75
|
with open(os.path.join(root, 'design.md'), 'w', encoding='utf-8') as stream:
|
|
54
76
|
stream.write(
|
|
55
77
|
'---\n'
|
|
@@ -77,6 +99,12 @@ class LayoutAssetContractTest(unittest.TestCase):
|
|
|
77
99
|
)
|
|
78
100
|
|
|
79
101
|
def write_background_pack(self, root):
|
|
102
|
+
self.write_asset(
|
|
103
|
+
root,
|
|
104
|
+
'backgrounds/content.webp',
|
|
105
|
+
b'background',
|
|
106
|
+
'assets/pptx-claude',
|
|
107
|
+
)
|
|
80
108
|
with open(os.path.join(root, 'design.md'), 'w', encoding='utf-8') as stream:
|
|
81
109
|
stream.write(
|
|
82
110
|
'---\n'
|
|
@@ -107,10 +135,14 @@ class LayoutAssetContractTest(unittest.TestCase):
|
|
|
107
135
|
root,
|
|
108
136
|
'<deck-stage>'
|
|
109
137
|
'<section data-pptx-layout="cover">'
|
|
110
|
-
'<img
|
|
138
|
+
'<img data-pptx-asset="logo-1" '
|
|
139
|
+
'style="position:absolute;left:64px;top:64px;width:224px;height:48px" '
|
|
140
|
+
'src="assets/pptx-volcengine/logos/1.png"></section>'
|
|
111
141
|
'<section data-pptx-layout="content"><h1>正文</h1></section>'
|
|
112
142
|
'<section data-pptx-layout="closing">'
|
|
113
|
-
'<img
|
|
143
|
+
'<img data-pptx-asset="logo-1" '
|
|
144
|
+
'style="position:absolute;left:64px;top:64px;width:224px;height:48px" '
|
|
145
|
+
'src="assets/pptx-volcengine/logos/1.png"></section>'
|
|
114
146
|
'</deck-stage>',
|
|
115
147
|
)
|
|
116
148
|
|
|
@@ -126,7 +158,9 @@ class LayoutAssetContractTest(unittest.TestCase):
|
|
|
126
158
|
root,
|
|
127
159
|
'<deck-stage>'
|
|
128
160
|
'<section data-pptx-layout="content">'
|
|
129
|
-
'<img
|
|
161
|
+
'<img data-pptx-asset="logo-1" '
|
|
162
|
+
'style="position:absolute;left:64px;top:64px;width:224px;height:48px" '
|
|
163
|
+
'src="assets/pptx-volcengine/logos/1.png"></section>'
|
|
130
164
|
'</deck-stage>',
|
|
131
165
|
)
|
|
132
166
|
|
|
@@ -142,7 +176,9 @@ class LayoutAssetContractTest(unittest.TestCase):
|
|
|
142
176
|
html = self.write_html(
|
|
143
177
|
root,
|
|
144
178
|
'<deck-stage><section data-pptx-layout="cover">'
|
|
145
|
-
'<section class="content"><img
|
|
179
|
+
'<section class="content"><img data-pptx-asset="logo-1" '
|
|
180
|
+
'style="position:absolute;left:64px;top:64px;width:224px;height:48px" '
|
|
181
|
+
'src="assets/pptx-volcengine/logos/1.png">'
|
|
146
182
|
'</section></section></deck-stage>',
|
|
147
183
|
)
|
|
148
184
|
|
|
@@ -209,7 +245,7 @@ class LayoutAssetContractTest(unittest.TestCase):
|
|
|
209
245
|
self.assertEqual(1, len(problems))
|
|
210
246
|
self.assertIn('cover', problems[0])
|
|
211
247
|
self.assertIn('texture-1', problems[0])
|
|
212
|
-
self.assertIn('
|
|
248
|
+
self.assertIn('缺少固定实例', problems[0])
|
|
213
249
|
|
|
214
250
|
def test_layout_bound_texture_on_an_unowned_layout_is_rejected(self):
|
|
215
251
|
with tempfile.TemporaryDirectory() as root:
|
|
@@ -217,7 +253,9 @@ class LayoutAssetContractTest(unittest.TestCase):
|
|
|
217
253
|
html = self.write_html(
|
|
218
254
|
root,
|
|
219
255
|
'<deck-stage><section data-pptx-layout="content">'
|
|
220
|
-
'<img
|
|
256
|
+
'<img data-pptx-asset="texture-1" '
|
|
257
|
+
'style="position:absolute;left:1142px;top:0;width:778px;height:1080px" '
|
|
258
|
+
'src="assets/pptx-claude/textures/1.webp"></section></deck-stage>',
|
|
221
259
|
)
|
|
222
260
|
|
|
223
261
|
problems = validate_layout_assets(root, html, 'assets/pptx-claude')
|
|
@@ -233,6 +271,7 @@ class LayoutAssetContractTest(unittest.TestCase):
|
|
|
233
271
|
html = self.write_html(
|
|
234
272
|
root,
|
|
235
273
|
'<deck-stage><section data-pptx-layout="content" '
|
|
274
|
+
'data-pptx-asset="bg-content-1" '
|
|
236
275
|
'style="background-image:url('
|
|
237
276
|
'\'assets/pptx-claude/backgrounds/content.webp?v=2#slide\')">'
|
|
238
277
|
'正文</section></deck-stage>',
|
|
@@ -261,14 +300,15 @@ class LayoutAssetContractTest(unittest.TestCase):
|
|
|
261
300
|
html = self.write_html(
|
|
262
301
|
root,
|
|
263
302
|
'<deck-stage><section data-pptx-layout="cover">'
|
|
264
|
-
'<img
|
|
303
|
+
'<img data-pptx-asset="texture-1" '
|
|
304
|
+
'style="position:absolute;left:0;top:0;width:100px;height:100px" '
|
|
305
|
+
'src="assets/pptx-claude/textures/1.webp"></section></deck-stage>',
|
|
265
306
|
)
|
|
266
307
|
|
|
267
308
|
problems = validate_layout_assets(root, html, 'assets/pptx-claude')
|
|
268
309
|
|
|
269
310
|
self.assertEqual(1, len(problems))
|
|
270
|
-
self.assertIn('
|
|
271
|
-
self.assertIn('缺少 1 处', problems[0])
|
|
311
|
+
self.assertIn('位置尺寸应为 [1800, 980, 100, 100]', problems[0])
|
|
272
312
|
|
|
273
313
|
def test_legacy_logo_scope_entrypoint_keeps_failure_exit_code(self):
|
|
274
314
|
with tempfile.TemporaryDirectory() as root:
|
|
@@ -296,6 +336,144 @@ class LayoutAssetContractTest(unittest.TestCase):
|
|
|
296
336
|
self.assertEqual(1, result.returncode)
|
|
297
337
|
self.assertIn('PPTX_LAYOUT_ASSETS: FAIL', result.stdout)
|
|
298
338
|
|
|
339
|
+
def test_copied_asset_must_keep_the_source_pptx_bytes(self):
|
|
340
|
+
with tempfile.TemporaryDirectory() as root:
|
|
341
|
+
self.write_texture_pack(root)
|
|
342
|
+
copied = os.path.join(
|
|
343
|
+
root, 'assets', 'pptx-claude', 'textures', '1.webp')
|
|
344
|
+
with open(copied, 'wb') as stream:
|
|
345
|
+
stream.write(b'generated replacement')
|
|
346
|
+
html = self.write_html(
|
|
347
|
+
root,
|
|
348
|
+
'<deck-stage><section data-pptx-layout="cover">'
|
|
349
|
+
'<img data-pptx-asset="texture-1" '
|
|
350
|
+
'style="position:absolute;left:1142px;top:0;width:778px;height:1080px" '
|
|
351
|
+
'src="assets/pptx-claude/textures/1.webp"></section></deck-stage>',
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
problems = validate_layout_assets(root, html, 'assets/pptx-claude')
|
|
355
|
+
|
|
356
|
+
self.assertEqual(1, len(problems))
|
|
357
|
+
self.assertIn('已被替换或改写', problems[0])
|
|
358
|
+
|
|
359
|
+
def test_fixed_asset_must_keep_its_layout_box(self):
|
|
360
|
+
with tempfile.TemporaryDirectory() as root:
|
|
361
|
+
self.write_texture_pack(root)
|
|
362
|
+
html = self.write_html(
|
|
363
|
+
root,
|
|
364
|
+
'<deck-stage><section data-pptx-layout="cover">'
|
|
365
|
+
'<img data-pptx-asset="texture-1" '
|
|
366
|
+
'style="position:absolute;left:0;top:0;width:778px;height:1080px" '
|
|
367
|
+
'src="assets/pptx-claude/textures/1.webp"></section></deck-stage>',
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
problems = validate_layout_assets(root, html, 'assets/pptx-claude')
|
|
371
|
+
|
|
372
|
+
self.assertEqual(1, len(problems))
|
|
373
|
+
self.assertIn('位置尺寸必须为 [1142, 0, 778, 1080]', problems[0])
|
|
374
|
+
|
|
375
|
+
def test_fixed_asset_cannot_be_hidden(self):
|
|
376
|
+
with tempfile.TemporaryDirectory() as root:
|
|
377
|
+
self.write_texture_pack(root)
|
|
378
|
+
html = self.write_html(
|
|
379
|
+
root,
|
|
380
|
+
'<deck-stage><section data-pptx-layout="cover">'
|
|
381
|
+
'<div hidden><img data-pptx-asset="texture-1" '
|
|
382
|
+
'style="position:absolute;left:1142px;top:0;width:778px;height:1080px" '
|
|
383
|
+
'src="assets/pptx-claude/textures/1.webp"></div>'
|
|
384
|
+
'</section></deck-stage>',
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
problems = validate_layout_assets(root, html, 'assets/pptx-claude')
|
|
388
|
+
|
|
389
|
+
self.assertEqual(1, len(problems))
|
|
390
|
+
self.assertIn('不可隐藏', problems[0])
|
|
391
|
+
|
|
392
|
+
def test_zero_sized_fixed_asset_is_hidden(self):
|
|
393
|
+
with tempfile.TemporaryDirectory() as root:
|
|
394
|
+
self.write_texture_pack(root)
|
|
395
|
+
html = self.write_html(
|
|
396
|
+
root,
|
|
397
|
+
'<deck-stage><section data-pptx-layout="cover">'
|
|
398
|
+
'<img data-pptx-asset="texture-1" '
|
|
399
|
+
'style="position:absolute;left:1142px;top:0;width:0;height:1080px" '
|
|
400
|
+
'src="assets/pptx-claude/textures/1.webp"></section></deck-stage>',
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
problems = validate_layout_assets(root, html, 'assets/pptx-claude')
|
|
404
|
+
|
|
405
|
+
self.assertEqual(2, len(problems))
|
|
406
|
+
self.assertTrue(any('不可隐藏' in problem for problem in problems))
|
|
407
|
+
self.assertTrue(any('位置尺寸必须为' in problem for problem in problems))
|
|
408
|
+
|
|
409
|
+
def test_fixed_asset_reference_requires_an_instance_marker(self):
|
|
410
|
+
with tempfile.TemporaryDirectory() as root:
|
|
411
|
+
self.write_texture_pack(root)
|
|
412
|
+
html = self.write_html(
|
|
413
|
+
root,
|
|
414
|
+
'<deck-stage><section data-pptx-layout="cover">'
|
|
415
|
+
'<img style="position:absolute;left:1142px;top:0;width:778px;height:1080px" '
|
|
416
|
+
'src="assets/pptx-claude/textures/1.webp"></section></deck-stage>',
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
problems = validate_layout_assets(root, html, 'assets/pptx-claude')
|
|
420
|
+
|
|
421
|
+
self.assertEqual(2, len(problems))
|
|
422
|
+
self.assertTrue(any(
|
|
423
|
+
'缺少 data-pptx-asset' in problem for problem in problems))
|
|
424
|
+
self.assertTrue(any(
|
|
425
|
+
'缺少固定实例 texture-1' in problem for problem in problems))
|
|
426
|
+
|
|
427
|
+
def test_fixed_asset_cannot_hide_a_generated_source_behind_the_original_url(self):
|
|
428
|
+
with tempfile.TemporaryDirectory() as root:
|
|
429
|
+
self.write_texture_pack(root)
|
|
430
|
+
html = self.write_html(
|
|
431
|
+
root,
|
|
432
|
+
'<deck-stage><section data-pptx-layout="cover">'
|
|
433
|
+
'<img data-pptx-asset="texture-1" '
|
|
434
|
+
'style="position:absolute;left:1142px;top:0;width:778px;height:1080px;'
|
|
435
|
+
'background-image:url(\'assets/pptx-claude/textures/1.webp\')" '
|
|
436
|
+
'src="assets/generated/replacement.webp"></section></deck-stage>',
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
problems = validate_layout_assets(root, html, 'assets/pptx-claude')
|
|
440
|
+
|
|
441
|
+
self.assertEqual(1, len(problems))
|
|
442
|
+
self.assertIn('未引用对应的 PPTX 原素材', problems[0])
|
|
443
|
+
|
|
444
|
+
def test_fixed_asset_nested_in_flow_is_checked_at_its_box(self):
|
|
445
|
+
with tempfile.TemporaryDirectory() as root:
|
|
446
|
+
self.write_texture_pack(root)
|
|
447
|
+
with open(os.path.join(root, 'layouts.md'), 'w', encoding='utf-8') as stream:
|
|
448
|
+
stream.write(
|
|
449
|
+
'---\n'
|
|
450
|
+
'canvas: 1920x1080\n'
|
|
451
|
+
'layouts:\n'
|
|
452
|
+
' cover:\n'
|
|
453
|
+
' role: cover\n'
|
|
454
|
+
' flow:\n'
|
|
455
|
+
' top: 80\n'
|
|
456
|
+
' margin: [80, 80]\n'
|
|
457
|
+
' gap: 32\n'
|
|
458
|
+
' regions:\n'
|
|
459
|
+
' - kind: free\n'
|
|
460
|
+
' items:\n'
|
|
461
|
+
' - {role: texture, type: pic, box: [1142, 0, 778, 1080], asset: texture-1}\n'
|
|
462
|
+
'---\n'
|
|
463
|
+
)
|
|
464
|
+
html = self.write_html(
|
|
465
|
+
root,
|
|
466
|
+
'<deck-stage><section data-pptx-layout="cover">'
|
|
467
|
+
'<img data-pptx-asset="texture-1" '
|
|
468
|
+
'style="position:absolute;left:1142px;top:0;width:778px;height:1080px" '
|
|
469
|
+
'src="assets/pptx-claude/textures/1.webp"></section></deck-stage>',
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
self.assertEqual(
|
|
473
|
+
[],
|
|
474
|
+
validate_layout_assets(root, html, 'assets/pptx-claude'),
|
|
475
|
+
)
|
|
476
|
+
|
|
299
477
|
|
|
300
478
|
if __name__ == '__main__':
|
|
301
479
|
unittest.main()
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""Verify that generated slides honor every asset bound to their PPTX layout."""
|
|
3
3
|
import argparse
|
|
4
|
+
import hashlib
|
|
5
|
+
import os
|
|
4
6
|
import posixpath
|
|
5
7
|
import re
|
|
6
8
|
import sys
|
|
@@ -40,14 +42,12 @@ def bound_asset_ids(value, known_asset_ids):
|
|
|
40
42
|
|
|
41
43
|
def layout_asset_contract(pack):
|
|
42
44
|
known_asset_ids = set(pack.assets)
|
|
43
|
-
required = {}
|
|
44
45
|
owners = {}
|
|
45
46
|
for layout_name, (layout, _) in pack.layouts.items():
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
for asset_id in asset_counts:
|
|
47
|
+
for asset_id, _, _ in layout_asset_instances(
|
|
48
|
+
layout, known_asset_ids, pack.canvas):
|
|
49
49
|
owners.setdefault(asset_id, set()).add(layout_name)
|
|
50
|
-
return
|
|
50
|
+
return owners
|
|
51
51
|
|
|
52
52
|
|
|
53
53
|
def asset_urls(pack, asset_prefix, asset_ids):
|
|
@@ -63,10 +63,118 @@ def asset_urls(pack, asset_prefix, asset_ids):
|
|
|
63
63
|
relative = normalized_path(path)
|
|
64
64
|
if relative.startswith('assets/'):
|
|
65
65
|
relative = relative[len('assets/'):]
|
|
66
|
-
urls
|
|
66
|
+
urls.setdefault(posixpath.join(prefix, relative), set()).add(asset_id)
|
|
67
67
|
return urls
|
|
68
68
|
|
|
69
69
|
|
|
70
|
+
def bound_asset_instances(value, known_asset_ids):
|
|
71
|
+
"""Return every positioned fixed-asset instance nested in slots or flow."""
|
|
72
|
+
instances = []
|
|
73
|
+
if isinstance(value, dict):
|
|
74
|
+
box = value.get('box')
|
|
75
|
+
asset_id = value.get('asset')
|
|
76
|
+
if (isinstance(box, list) and len(box) == 4
|
|
77
|
+
and isinstance(asset_id, str)
|
|
78
|
+
and asset_id in known_asset_ids):
|
|
79
|
+
instances.append((asset_id, value.get('role') or 'asset', box))
|
|
80
|
+
for key, child in value.items():
|
|
81
|
+
if key not in ('asset', 'background'):
|
|
82
|
+
instances.extend(bound_asset_instances(child, known_asset_ids))
|
|
83
|
+
elif isinstance(value, list):
|
|
84
|
+
for child in value:
|
|
85
|
+
instances.extend(bound_asset_instances(child, known_asset_ids))
|
|
86
|
+
return instances
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def layout_asset_instances(layout, known_asset_ids, canvas):
|
|
90
|
+
"""Return every fixed image instance declared by one layout."""
|
|
91
|
+
if not isinstance(layout, dict):
|
|
92
|
+
return []
|
|
93
|
+
instances = []
|
|
94
|
+
background = layout.get('background')
|
|
95
|
+
if canvas and isinstance(background, str) and background in known_asset_ids:
|
|
96
|
+
instances.append(
|
|
97
|
+
(background, 'background', [0, 0, canvas[0], canvas[1]]))
|
|
98
|
+
instances.extend(bound_asset_instances(layout, known_asset_ids))
|
|
99
|
+
return instances
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def inline_styles(value):
|
|
103
|
+
styles = {}
|
|
104
|
+
for declaration in (value or '').split(';'):
|
|
105
|
+
if ':' not in declaration:
|
|
106
|
+
continue
|
|
107
|
+
name, raw = declaration.split(':', 1)
|
|
108
|
+
styles[name.strip().lower()] = re.sub(
|
|
109
|
+
r'\s*!important\s*$', '', raw.strip().lower())
|
|
110
|
+
return styles
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def css_number(value):
|
|
114
|
+
match = re.fullmatch(r'(-?(?:\d+(?:\.\d*)?|\.\d+))(?:px)?', value or '')
|
|
115
|
+
return float(match.group(1)) if match else None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def element_box(reference, canvas):
|
|
119
|
+
styles = reference['styles']
|
|
120
|
+
if reference['slide_root'] and not any(
|
|
121
|
+
name in styles for name in ('left', 'top', 'width', 'height')):
|
|
122
|
+
return [0.0, 0.0, float(canvas[0]), float(canvas[1])]
|
|
123
|
+
values = [
|
|
124
|
+
css_number(styles.get(name))
|
|
125
|
+
for name in ('left', 'top', 'width', 'height')
|
|
126
|
+
]
|
|
127
|
+
if styles.get('position') != 'absolute' or any(
|
|
128
|
+
value is None for value in values):
|
|
129
|
+
return None
|
|
130
|
+
return values
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def boxes_match(actual, expected, tolerance=1.0):
|
|
134
|
+
return actual is not None and all(
|
|
135
|
+
abs(actual_value - expected_value) <= tolerance
|
|
136
|
+
for actual_value, expected_value in zip(actual, expected)
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def file_sha256(path):
|
|
141
|
+
digest = hashlib.sha256()
|
|
142
|
+
with open(path, 'rb') as stream:
|
|
143
|
+
for chunk in iter(lambda: stream.read(1024 * 1024), b''):
|
|
144
|
+
digest.update(chunk)
|
|
145
|
+
return digest.hexdigest()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def copied_asset_problems(pack, html_path, asset_prefix, asset_ids):
|
|
149
|
+
"""Verify copied fixed assets still contain the source PPTX bytes."""
|
|
150
|
+
problems = []
|
|
151
|
+
html_root = os.path.dirname(os.path.abspath(html_path))
|
|
152
|
+
prefix = normalized_path(asset_prefix).rstrip('/')
|
|
153
|
+
for asset_id in sorted(asset_ids):
|
|
154
|
+
entry, _ = pack.assets.get(asset_id, (None, None))
|
|
155
|
+
path = entry.get('path') if isinstance(entry, dict) else None
|
|
156
|
+
if not isinstance(path, str) or not path:
|
|
157
|
+
continue
|
|
158
|
+
relative = normalized_path(path)
|
|
159
|
+
if relative.startswith('assets/'):
|
|
160
|
+
relative = relative[len('assets/'):]
|
|
161
|
+
source_path = os.path.join(pack.root, 'assets', *relative.split('/'))
|
|
162
|
+
copied_relative = posixpath.join(prefix, relative)
|
|
163
|
+
copied_path = os.path.join(html_root, *copied_relative.split('/'))
|
|
164
|
+
if not os.path.isfile(copied_path):
|
|
165
|
+
problems.append(
|
|
166
|
+
'固定素材 %s 未复制到项目: %s' % (asset_id, copied_relative))
|
|
167
|
+
continue
|
|
168
|
+
if not os.path.isfile(source_path):
|
|
169
|
+
problems.append(
|
|
170
|
+
'风格包中的固定素材 %s 不存在: %s' % (asset_id, path))
|
|
171
|
+
continue
|
|
172
|
+
if file_sha256(copied_path) != file_sha256(source_path):
|
|
173
|
+
problems.append(
|
|
174
|
+
'固定素材 %s 已被替换或改写,必须使用 PPTX 原文件' % asset_id)
|
|
175
|
+
return problems
|
|
176
|
+
|
|
177
|
+
|
|
70
178
|
def urls_from_attrs(attrs):
|
|
71
179
|
urls = []
|
|
72
180
|
for key, value in attrs:
|
|
@@ -85,50 +193,71 @@ class SlideAssetParser(HTMLParser):
|
|
|
85
193
|
def __init__(self):
|
|
86
194
|
super().__init__()
|
|
87
195
|
self.slides = []
|
|
88
|
-
self.
|
|
89
|
-
self._sections = []
|
|
90
|
-
self._active_slides = []
|
|
196
|
+
self._stack = []
|
|
91
197
|
self._style_depth = 0
|
|
92
198
|
self.outside_urls = []
|
|
93
199
|
|
|
94
200
|
def handle_starttag(self, tag, attrs):
|
|
95
201
|
tag = tag.lower()
|
|
202
|
+
attrs_map = {key.lower(): value for key, value in attrs}
|
|
96
203
|
urls = urls_from_attrs(attrs)
|
|
97
|
-
parent = self.
|
|
98
|
-
if
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
204
|
+
parent = self._stack[-1] if self._stack else None
|
|
205
|
+
parent_slide = parent['slide'] if parent else None
|
|
206
|
+
slide_root = bool(
|
|
207
|
+
tag == 'section' and parent and parent['tag'] == 'deck-stage')
|
|
208
|
+
slide = ({
|
|
209
|
+
'layout': attrs_map.get('data-pptx-layout'),
|
|
210
|
+
'references': [],
|
|
211
|
+
} if slide_root else parent_slide)
|
|
212
|
+
if slide_root:
|
|
213
|
+
self.slides.append(slide)
|
|
214
|
+
styles = inline_styles(attrs_map.get('style'))
|
|
215
|
+
hidden = bool(
|
|
216
|
+
(parent and parent['hidden'])
|
|
217
|
+
or 'hidden' in attrs_map
|
|
218
|
+
or attrs_map.get('aria-hidden', '').lower() == 'true'
|
|
219
|
+
or styles.get('display') == 'none'
|
|
220
|
+
or styles.get('visibility') in ('hidden', 'collapse')
|
|
221
|
+
or styles.get('content-visibility') == 'hidden'
|
|
222
|
+
or css_number(styles.get('width')) == 0
|
|
223
|
+
or css_number(styles.get('height')) == 0
|
|
224
|
+
or (
|
|
225
|
+
css_number(styles.get('opacity')) is not None
|
|
226
|
+
and css_number(styles.get('opacity')) <= 0
|
|
102
227
|
)
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
228
|
+
)
|
|
229
|
+
if slide is not None and (urls or attrs_map.get('data-pptx-asset')):
|
|
230
|
+
slide['references'].append({
|
|
231
|
+
'asset': attrs_map.get('data-pptx-asset'),
|
|
232
|
+
'hidden': hidden,
|
|
233
|
+
'slide_root': slide_root,
|
|
234
|
+
'source': attrs_map.get('src'),
|
|
235
|
+
'styles': styles,
|
|
236
|
+
'tag': tag,
|
|
237
|
+
'urls': urls,
|
|
238
|
+
})
|
|
239
|
+
elif urls:
|
|
109
240
|
self.outside_urls.extend(urls)
|
|
110
241
|
if tag == 'style':
|
|
111
242
|
self._style_depth += 1
|
|
112
243
|
if tag not in VOID_TAGS:
|
|
113
|
-
self.
|
|
244
|
+
self._stack.append({
|
|
245
|
+
'hidden': hidden,
|
|
246
|
+
'slide': slide,
|
|
247
|
+
'tag': tag,
|
|
248
|
+
})
|
|
114
249
|
|
|
115
250
|
def handle_startendtag(self, tag, attrs):
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
self.outside_urls.extend(urls_from_attrs(attrs))
|
|
251
|
+
self.handle_starttag(tag, attrs)
|
|
252
|
+
if tag.lower() not in VOID_TAGS:
|
|
253
|
+
self.handle_endtag(tag)
|
|
120
254
|
|
|
121
255
|
def handle_endtag(self, tag):
|
|
122
256
|
tag = tag.lower()
|
|
123
257
|
if tag == 'style' and self._style_depth:
|
|
124
258
|
self._style_depth -= 1
|
|
125
|
-
if tag
|
|
126
|
-
|
|
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()
|
|
259
|
+
if tag not in VOID_TAGS and self._stack:
|
|
260
|
+
self._stack.pop()
|
|
132
261
|
|
|
133
262
|
def handle_data(self, data):
|
|
134
263
|
if not self._style_depth:
|
|
@@ -139,10 +268,15 @@ class SlideAssetParser(HTMLParser):
|
|
|
139
268
|
def validate_layout_assets(pack_dir, html_path, asset_prefix):
|
|
140
269
|
"""Return violations of the asset contract declared by each layout."""
|
|
141
270
|
pack = Pack(pack_dir)
|
|
142
|
-
|
|
271
|
+
owners = layout_asset_contract(pack)
|
|
143
272
|
known_urls = asset_urls(pack, asset_prefix, owners)
|
|
144
273
|
if not known_urls:
|
|
145
274
|
return []
|
|
275
|
+
asset_urls_by_id = {
|
|
276
|
+
asset_id: url
|
|
277
|
+
for url, asset_ids in known_urls.items()
|
|
278
|
+
for asset_id in asset_ids
|
|
279
|
+
}
|
|
146
280
|
|
|
147
281
|
with open(html_path, encoding='utf-8') as stream:
|
|
148
282
|
text = stream.read()
|
|
@@ -150,10 +284,9 @@ def validate_layout_assets(pack_dir, html_path, asset_prefix):
|
|
|
150
284
|
parser.feed(text)
|
|
151
285
|
parser.close()
|
|
152
286
|
|
|
153
|
-
problems =
|
|
287
|
+
problems = copied_asset_problems(pack, html_path, asset_prefix, owners)
|
|
154
288
|
for url in parser.outside_urls:
|
|
155
|
-
asset_id
|
|
156
|
-
if asset_id:
|
|
289
|
+
for asset_id in sorted(known_urls.get(normalized_path(url), ())):
|
|
157
290
|
problems.append(
|
|
158
291
|
'模板资产 %s 出现在 slide section 外,无法核验页型归属' % asset_id)
|
|
159
292
|
for number, slide in enumerate(parser.slides, 1):
|
|
@@ -164,23 +297,84 @@ def validate_layout_assets(pack_dir, html_path, asset_prefix):
|
|
|
164
297
|
if layout not in pack.layouts:
|
|
165
298
|
problems.append('第 %d 页声明了不存在的模板页型: %s' % (number, layout))
|
|
166
299
|
continue
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
300
|
+
expected = layout_asset_instances(
|
|
301
|
+
pack.layouts[layout][0], set(pack.assets), pack.canvas)
|
|
302
|
+
actual = []
|
|
303
|
+
for reference in slide['references']:
|
|
304
|
+
referenced_ids = set()
|
|
305
|
+
normalized_urls = [normalized_path(url) for url in reference['urls']]
|
|
306
|
+
for url in normalized_urls:
|
|
307
|
+
referenced_ids.update(known_urls.get(url, ()))
|
|
308
|
+
asset_id = reference['asset']
|
|
309
|
+
if not asset_id:
|
|
310
|
+
for referenced_id in sorted(referenced_ids):
|
|
311
|
+
problems.append(
|
|
312
|
+
'第 %d 页模板资产 %s 缺少 data-pptx-asset 实例标记'
|
|
313
|
+
% (number, referenced_id))
|
|
314
|
+
continue
|
|
315
|
+
if asset_id not in pack.assets:
|
|
316
|
+
problems.append(
|
|
317
|
+
'第 %d 页声明了不存在的模板资产: %s' % (number, asset_id))
|
|
318
|
+
continue
|
|
319
|
+
expected_url = asset_urls_by_id.get(asset_id)
|
|
320
|
+
uses_expected_source = (
|
|
321
|
+
len(normalized_urls) == 1
|
|
322
|
+
and normalized_urls[0] == expected_url
|
|
323
|
+
and (
|
|
324
|
+
reference['tag'] != 'img'
|
|
325
|
+
or (
|
|
326
|
+
reference['source']
|
|
327
|
+
and normalized_path(reference['source']) == expected_url
|
|
328
|
+
)
|
|
329
|
+
)
|
|
330
|
+
)
|
|
331
|
+
if not uses_expected_source:
|
|
332
|
+
problems.append(
|
|
333
|
+
'第 %d 页固定实例 %s 未引用对应的 PPTX 原素材'
|
|
334
|
+
% (number, asset_id))
|
|
335
|
+
if reference['hidden']:
|
|
336
|
+
problems.append(
|
|
337
|
+
'第 %d 页固定实例 %s 不可隐藏' % (number, asset_id))
|
|
338
|
+
actual.append({
|
|
339
|
+
'asset': asset_id,
|
|
340
|
+
'box': element_box(reference, pack.canvas),
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
used_asset_counts = Counter(instance['asset'] for instance in actual)
|
|
172
344
|
for asset_id in sorted(used_asset_counts):
|
|
173
345
|
if layout not in owners.get(asset_id, set()):
|
|
174
346
|
allowed = '、'.join(sorted(owners.get(asset_id) or ())) or '(无)'
|
|
175
347
|
problems.append(
|
|
176
348
|
'第 %d 页页型 %s 不得使用 %s;只允许: %s'
|
|
177
349
|
% (number, layout, asset_id, allowed))
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
350
|
+
unmatched = list(actual)
|
|
351
|
+
for asset_id, _, expected_box in expected:
|
|
352
|
+
matching_index = next((
|
|
353
|
+
index for index, instance in enumerate(unmatched)
|
|
354
|
+
if instance['asset'] == asset_id
|
|
355
|
+
and boxes_match(instance['box'], expected_box)
|
|
356
|
+
), None)
|
|
357
|
+
if matching_index is not None:
|
|
358
|
+
unmatched.pop(matching_index)
|
|
359
|
+
continue
|
|
360
|
+
same_asset = next((
|
|
361
|
+
instance for instance in unmatched
|
|
362
|
+
if instance['asset'] == asset_id
|
|
363
|
+
), None)
|
|
364
|
+
if same_asset:
|
|
365
|
+
problems.append(
|
|
366
|
+
'第 %d 页固定实例 %s 的位置尺寸必须为 %s,当前为 %s'
|
|
367
|
+
% (number, asset_id, expected_box, same_asset['box']))
|
|
368
|
+
unmatched.remove(same_asset)
|
|
369
|
+
else:
|
|
370
|
+
problems.append(
|
|
371
|
+
'第 %d 页页型 %s 缺少固定实例 %s,位置尺寸应为 %s'
|
|
372
|
+
% (number, layout, asset_id, expected_box))
|
|
373
|
+
for instance in unmatched:
|
|
374
|
+
if layout in owners.get(instance['asset'], set()):
|
|
181
375
|
problems.append(
|
|
182
|
-
'第 %d 页页型 %s
|
|
183
|
-
% (number, layout,
|
|
376
|
+
'第 %d 页页型 %s 额外使用了固定实例 %s'
|
|
377
|
+
% (number, layout, instance['asset']))
|
|
184
378
|
return problems
|
|
185
379
|
|
|
186
380
|
|