@lark-apaas/coding-steering 0.1.32-dev.942e73f → 0.1.32-dev.b2f659e

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lark-apaas/coding-steering",
3
- "version": "0.1.32-dev.942e73f",
3
+ "version": "0.1.32-dev.b2f659e",
4
4
  "description": "Stack-specific steering content for miaoda-coding templates",
5
5
  "type": "module",
6
6
  "files": [
@@ -133,16 +133,16 @@ When generating a deck:
133
133
  2. Build `<deck-stage width="<canvas width>" height="<canvas height>">` using the `canvas` declared in `layouts.md` — source decks are not always 16:9, and a default-sized stage shifts every coordinate on the page. Each slide is one static `<section data-pptx-layout="<chosen archetype>">`.
134
134
  3. Inline CSS variables from `design.md` into the HTML `<style>` block using a `--ppt-*` prefix.
135
135
  4. **位移动画用独立的 `translate` 属性**:`@keyframes fadeUp { from{opacity:0; translate:0 24px} to{opacity:1; translate:0 0} }`。`transform` 是单一属性,动画里碰它会覆盖掉元素原有的那条(`left:50%; transform:translateX(-50%)` 的居中就此丢失);`translate` / `rotate` / `scale` 各自独立,与已有 `transform` 叠加。
136
- 5. Map `layouts.md` slots to absolute-positioned elements inside each section: expand `box: [x,y,w,h]` mechanically to `left/top/width/height`, then apply the slot's `css` declaration string unchanged. `box` owns geometry; `css` owns all rendering style, including the template's text padding, typography, alignment, line height, letter spacing, and rotation. Do not reinterpret PPTX fields or replace slot CSS with your own type scale. A slot with `asset` is a fixed image element (logo, mark): place that asset at that box on that archetype only. Do not reflow any of this as generic web grids.
136
+ 5. Map `layouts.md` slots to absolute-positioned elements inside each section: expand `box: [x,y,w,h]` mechanically to `left/top/width/height`, then apply the slot's `css` declaration string unchanged. `box` owns geometry; `css` owns all rendering style, including the template's text padding, typography, alignment, line height, letter spacing, and rotation. Do not reinterpret PPTX fields or replace slot CSS with your own type scale. A slot with `asset` is a fixed image element: use that exact asset at that box on that archetype only; do not omit or replace it. Do not reflow any of this as generic web grids.
137
137
  6. Copy `<pack_dir>/assets/` into the project, for example `assets/pptx-style/<pack-name>/`, and reference those copied files with relative URLs. The scratch `tmp/pptx-style-extract/...` path must never appear in the final HTML.
138
138
  7. Use the font stacks and fallback rules from `design.md`; do not install fonts or dependencies at runtime.
139
- 8. Before the regular slide preflight, verify every template logo against the selected archetype:
139
+ 8. Before the regular slide preflight, verify every background and fixed image asset against the selected archetype:
140
140
 
141
141
  ```bash
142
- PYTHONDONTWRITEBYTECODE=1 python3 -B scripts/verify_logo_scope.py <pack_dir> <index.html> --asset-prefix <copied-assets-prefix>
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_LOGO_SCOPE: FAIL` means remove or move every listed logo according to `layouts.md`, then rerun this command.
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
@@ -981,6 +981,13 @@ def background_decor(background, canvas):
981
981
  'trace': 'canvas-background'}
982
982
 
983
983
 
984
+ def background_identity(background):
985
+ """返回可用于聚类和审计的稳定背景标识,不改变背景的原始表达。"""
986
+ if isinstance(background, dict):
987
+ return json.dumps(background, sort_keys=True, separators=(',', ':'))
988
+ return background
989
+
990
+
984
991
  # ---------------------------------------------------------------- 版式聚类
985
992
  DECOR_MIN = 40.0
986
993
 
@@ -1539,16 +1546,19 @@ def draft_layouts(d, outdir, effective_alpha=None):
1539
1546
  image_marks = slide_image_marks(d, overlay_media)
1540
1547
 
1541
1548
  bg_of_slide, layout_of_slide = {}, {}
1542
- bg_of_layout = {row['part']: row.get('background') for row in d.get('layouts') or []}
1549
+ background_of_layout = {
1550
+ row['part']: row.get('background') for row in d.get('layouts') or []
1551
+ }
1543
1552
  for s in d.get('slides', []):
1544
1553
  bg = s.get('background')
1545
- bg_of_slide[s['part']] = json.dumps(bg, sort_keys=True) if isinstance(bg, dict) else bg
1554
+ bg_of_slide[s['part']] = background_identity(bg)
1546
1555
  layout_of_slide[s['part']] = s.get('layout')
1547
1556
  # 版式层的满屏底图(form=2 常态:底图挂在 layout 上)
1548
1557
  composites = d.get('background_composites') or {}
1558
+ bg_media_of_layout = {}
1549
1559
  for s in shapes:
1550
1560
  if s.get('layer') == 'layout' and is_bleed(s) and s.get('media'):
1551
- bg_of_layout[s['part']] = s['media']
1561
+ bg_media_of_layout[s['part']] = s['media']
1552
1562
 
1553
1563
  pages = []
1554
1564
  for part, sh in sorted(by_slide.items(), key=lambda kv: slide_no(kv[0])):
@@ -1556,7 +1566,7 @@ def draft_layouts(d, outdir, effective_alpha=None):
1556
1566
  layout_shapes = by_layout.get(layout_part) or []
1557
1567
  bg_media = top_bleed_media(sh)
1558
1568
  if bg_media is None:
1559
- bg_media = bg_of_layout.get(layout_part)
1569
+ bg_media = bg_media_of_layout.get(layout_part)
1560
1570
  rendered_bg = (composites.get(part)
1561
1571
  or composites.get(layout_part)
1562
1572
  or bg_media)
@@ -1602,7 +1612,7 @@ def draft_layouts(d, outdir, effective_alpha=None):
1602
1612
  marks.append(mark)
1603
1613
  background = next((s.get('background') for s in d.get('slides') or []
1604
1614
  if s.get('part') == part and s.get('background')), None)
1605
- background = background or bg_of_layout.get(layout_part)
1615
+ background = background or background_of_layout.get(layout_part)
1606
1616
  pages.append({'part': part, 'no': slide_no(part), 'bg_media': bg_media,
1607
1617
  'rendered_bg': rendered_bg,
1608
1618
  'bg_color': bg_of_slide.get(part), 'background': background,
@@ -1632,7 +1642,11 @@ def draft_layouts(d, outdir, effective_alpha=None):
1632
1642
  # 不替模型下结论。和首页一样,拆组只避免它被聚类代表页吞掉。
1633
1643
  groups[('__last__', -2)] = [p]
1634
1644
  continue
1635
- groups[(p['bg_media'] or p['bg_color'] or 'none', density_band(p))].append(p)
1645
+ background_key = background_identity(
1646
+ p.get('rendered_bg') or p.get('bg_media')
1647
+ or p.get('background') or p.get('bg_color')
1648
+ ) or 'none'
1649
+ groups[(background_key, density_band(p))].append(p)
1636
1650
 
1637
1651
  ranked = sorted(groups.items(), key=lambda kv: (-len(kv[1]), kv[1][0]['no']))
1638
1652
  # 首页所在的组一定收——deck 的第一页是模板的门面,孤例也不能被名额挤掉。
@@ -1654,15 +1668,9 @@ def draft_layouts(d, outdir, effective_alpha=None):
1654
1668
  leftover = sorted(p['no'] for g in ranked if g not in kept for p in g[1])
1655
1669
 
1656
1670
  archetypes = []
1657
- for gi, ((bg_raw, _band), ps) in enumerate(kept, 1):
1671
+ for gi, ((_background_key, _band), ps) in enumerate(kept, 1):
1658
1672
  rep = max(ps, key=lambda p: len(p['texts']))
1659
- if bg_raw == '__first__':
1660
- bg_raw = rep['bg_media'] or rep['bg_color'] or 'none'
1661
- elif bg_raw == '__last__':
1662
- bg_raw = rep['bg_media'] or rep['bg_color'] or 'none'
1663
- rendered_bg = rep.get('rendered_bg')
1664
- if rendered_bg:
1665
- bg_raw = rendered_bg
1673
+ bg_raw = rep.get('rendered_bg') or rep.get('bg_media')
1666
1674
  name = 'layout-%d' % gi
1667
1675
  # 标题按「位置 + 跨度」认,不按字号——big-number 类的巨号数值常比标题还大
1668
1676
  # 标题 = 该页最靠上的那批文本里最宽的一块。不按「画布前 28%」这类固定比例切:
@@ -1739,9 +1747,13 @@ def draft_layouts(d, outdir, effective_alpha=None):
1739
1747
  p['layout'] for p in ps if p.get('layout')
1740
1748
  }),
1741
1749
  '_source_backgrounds': sorted({
1742
- p.get('rendered_bg') or p.get('bg_media') or p.get('bg_color')
1750
+ background_identity(
1751
+ p.get('rendered_bg') or p.get('bg_media')
1752
+ or p.get('background') or p.get('bg_color')
1753
+ )
1743
1754
  for p in ps
1744
- if p.get('rendered_bg') or p.get('bg_media') or p.get('bg_color')
1755
+ if (p.get('rendered_bg') or p.get('bg_media')
1756
+ or p.get('background') or p.get('bg_color'))
1745
1757
  }),
1746
1758
  '_text_n': len(rep['texts']),
1747
1759
  '_last_page_candidate': rep['no'] == last_page_no,
@@ -3044,7 +3056,7 @@ def emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, l
3044
3056
  '页数多于页型时,挑最接近的一个原样套用它的 slot:用不到的槽删掉,'
3045
3057
  '内容比槽多就按同类槽的间距等距加,**坐标一律沿用该页型给的那套,不要自己另起网格**。'
3046
3058
  '每个生成页面的 `<section>` 都写 `data-pptx-layout="<页型名>"`,'
3047
- '交付前据此核验 logo 只出现在该页型的资产槽中。' % sidecar,
3059
+ '交付前据此核验该页型绑定的背景与图片资产均已使用,且没有跨页型误用。' % sidecar,
3048
3060
  '3. **按页型给的形态落元素** —— 页型给 `flow` 就用流式,给 `slots` 就用绝对,'
3049
3061
  '两者只会出现一个。'
3050
3062
  '**flow**:整块用一个纵向 flex 容器,`top` 是它的起始 y,`margin` 是整块的左右边距,'
@@ -3063,8 +3075,13 @@ def emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, l
3063
3075
  '`[x, y, w, h]`(%dx%d 画布上的绝对像素),机械展开成 `left/top/width/height`;'
3064
3076
  'slot 的 `css` 是模板排版属性已转译好的声明串,原样写进 style,不要另选字号、'
3065
3077
  '内边距、颜色或对齐。'
3066
- '带 `asset` 的 slot 是图片元素(logo、角标),把该资产放在它自己的 `box` 里;'
3067
- '这个页型没有 `asset` 槽,这一页就不出现该资产。' % (canvas[0], canvas[1]),
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]),
3068
3085
  '5. **铺装饰几何** —— 页型的 `decor` 是这一页的图形骨架(图标托底的圆、'
3069
3086
  '卡片、分隔线):每条渲染成一个绝对定位空元素,`box` 给位置,`css` 逐项原样写进 '
3070
3087
  'style;没有 `border-radius` 就按 `0`。只有 `geom: ellipse` 另加 '
@@ -61,7 +61,15 @@ 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('交付前据此核验 logo 只出现在该页型的资产槽中', 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)
71
+ self.assertIn('该页型绑定的背景与图片资产均已使用', body)
72
+ self.assertIn('不得省略或换图', body)
65
73
 
66
74
 
67
75
  if __name__ == '__main__':
@@ -1185,6 +1185,85 @@ layouts:
1185
1185
  self.assertIn('value: "[0, 0, 1920, 1080]"', manifest)
1186
1186
  self.assertIn('reason: "PPT 背景铺满画布"', manifest)
1187
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
+
1188
1267
  def test_asset_vision_groups_fail_when_page_screenshot_is_unreadable(self):
1189
1268
  candidates = [{
1190
1269
  'id': 'asset-1',
@@ -1,17 +1,34 @@
1
1
  #!/usr/bin/env python3
2
- """Regression tests for template logo placement in generated deck HTML."""
2
+ """Regression tests for template asset placement in generated deck HTML."""
3
3
  import os
4
+ import shutil
5
+ import subprocess
4
6
  import sys
5
7
  import tempfile
6
8
  import unittest
7
9
 
8
10
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
9
11
 
10
- from verify_logo_scope import validate_logo_scope
12
+ from verify_layout_assets import validate_layout_assets
11
13
 
12
14
 
13
- class LogoScopeTest(unittest.TestCase):
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
+
14
25
  def write_pack(self, root):
26
+ self.write_asset(
27
+ root,
28
+ 'logos/1.png',
29
+ b'logo',
30
+ 'assets/pptx-volcengine',
31
+ )
15
32
  with open(os.path.join(root, 'design.md'), 'w', encoding='utf-8') as stream:
16
33
  stream.write(
17
34
  '---\n'
@@ -48,6 +65,69 @@ class LogoScopeTest(unittest.TestCase):
48
65
  stream.write(content)
49
66
  return path
50
67
 
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
+ )
75
+ with open(os.path.join(root, 'design.md'), 'w', encoding='utf-8') as stream:
76
+ stream.write(
77
+ '---\n'
78
+ 'assets:\n'
79
+ ' texture-1:\n'
80
+ ' path: assets/textures/1.webp\n'
81
+ ' kind: texture\n'
82
+ 'layouts: layouts.md\n'
83
+ '---\n'
84
+ )
85
+ with open(os.path.join(root, 'layouts.md'), 'w', encoding='utf-8') as stream:
86
+ stream.write(
87
+ '---\n'
88
+ 'canvas: 1920x1080\n'
89
+ 'layouts:\n'
90
+ ' cover:\n'
91
+ ' role: cover\n'
92
+ ' slots:\n'
93
+ ' - {role: texture, box: [1142, 0, 778, 1080], type: pic, asset: texture-1}\n'
94
+ ' content:\n'
95
+ ' role: content\n'
96
+ ' slots:\n'
97
+ ' - {role: title, box: [100, 100, 800, 100], type: title}\n'
98
+ '---\n'
99
+ )
100
+
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
+ )
108
+ with open(os.path.join(root, 'design.md'), 'w', encoding='utf-8') as stream:
109
+ stream.write(
110
+ '---\n'
111
+ 'assets:\n'
112
+ ' bg-content-1:\n'
113
+ ' path: assets/backgrounds/content.webp\n'
114
+ ' kind: background\n'
115
+ 'layouts: layouts.md\n'
116
+ '---\n'
117
+ )
118
+ with open(os.path.join(root, 'layouts.md'), 'w', encoding='utf-8') as stream:
119
+ stream.write(
120
+ '---\n'
121
+ 'canvas: 1920x1080\n'
122
+ 'layouts:\n'
123
+ ' content:\n'
124
+ ' role: content\n'
125
+ ' background: bg-content-1\n'
126
+ ' slots:\n'
127
+ ' - {role: title, box: [100, 100, 800, 100], type: title}\n'
128
+ '---\n'
129
+ )
130
+
51
131
  def test_logo_is_limited_to_every_archetype_with_its_slot(self):
52
132
  with tempfile.TemporaryDirectory() as root:
53
133
  self.write_pack(root)
@@ -55,16 +135,20 @@ class LogoScopeTest(unittest.TestCase):
55
135
  root,
56
136
  '<deck-stage>'
57
137
  '<section data-pptx-layout="cover">'
58
- '<img src="assets/pptx-volcengine/logos/1.png"></section>'
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>'
59
141
  '<section data-pptx-layout="content"><h1>正文</h1></section>'
60
142
  '<section data-pptx-layout="closing">'
61
- '<img src="assets/pptx-volcengine/logos/1.png"></section>'
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>'
62
146
  '</deck-stage>',
63
147
  )
64
148
 
65
149
  self.assertEqual(
66
150
  [],
67
- validate_logo_scope(root, html, 'assets/pptx-volcengine'),
151
+ validate_layout_assets(root, html, 'assets/pptx-volcengine'),
68
152
  )
69
153
 
70
154
  def test_logo_on_an_unowned_archetype_is_rejected(self):
@@ -74,11 +158,13 @@ class LogoScopeTest(unittest.TestCase):
74
158
  root,
75
159
  '<deck-stage>'
76
160
  '<section data-pptx-layout="content">'
77
- '<img src="assets/pptx-volcengine/logos/1.png"></section>'
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>'
78
164
  '</deck-stage>',
79
165
  )
80
166
 
81
- problems = validate_logo_scope(root, html, 'assets/pptx-volcengine')
167
+ problems = validate_layout_assets(root, html, 'assets/pptx-volcengine')
82
168
 
83
169
  self.assertEqual(1, len(problems))
84
170
  self.assertIn('content', problems[0])
@@ -90,13 +176,15 @@ class LogoScopeTest(unittest.TestCase):
90
176
  html = self.write_html(
91
177
  root,
92
178
  '<deck-stage><section data-pptx-layout="cover">'
93
- '<section class="content"><img src="assets/pptx-volcengine/logos/1.png">'
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">'
94
182
  '</section></section></deck-stage>',
95
183
  )
96
184
 
97
185
  self.assertEqual(
98
186
  [],
99
- validate_logo_scope(root, html, 'assets/pptx-volcengine'),
187
+ validate_layout_assets(root, html, 'assets/pptx-volcengine'),
100
188
  )
101
189
 
102
190
  def test_each_slide_declares_its_template_archetype(self):
@@ -108,8 +196,8 @@ class LogoScopeTest(unittest.TestCase):
108
196
  )
109
197
 
110
198
  self.assertEqual(
111
- ['第 1 页缺少 data-pptx-layout,无法核验模板 logo 归属'],
112
- validate_logo_scope(root, html, 'assets/pptx-volcengine'),
199
+ ['第 1 页缺少 data-pptx-layout,无法核验模板资产归属'],
200
+ validate_layout_assets(root, html, 'assets/pptx-volcengine'),
113
201
  )
114
202
 
115
203
  def test_logo_in_global_css_is_rejected(self):
@@ -123,8 +211,8 @@ class LogoScopeTest(unittest.TestCase):
123
211
  )
124
212
 
125
213
  self.assertEqual(
126
- ['模板 logo logo-1 出现在 slide section 外,无法核验页型归属'],
127
- validate_logo_scope(root, html, 'assets/pptx-volcengine'),
214
+ ['模板资产 logo-1 出现在 slide section 外,无法核验页型归属'],
215
+ validate_layout_assets(root, html, 'assets/pptx-volcengine'),
128
216
  )
129
217
 
130
218
  def test_logo_in_a_slide_style_block_is_rejected(self):
@@ -139,8 +227,251 @@ class LogoScopeTest(unittest.TestCase):
139
227
  )
140
228
 
141
229
  self.assertEqual(
142
- ['模板 logo logo-1 出现在 slide section 外,无法核验页型归属'],
143
- validate_logo_scope(root, html, 'assets/pptx-volcengine'),
230
+ ['模板资产 logo-1 出现在 slide section 外,无法核验页型归属'],
231
+ validate_layout_assets(root, html, 'assets/pptx-volcengine'),
232
+ )
233
+
234
+ def test_layout_must_use_its_bound_texture(self):
235
+ with tempfile.TemporaryDirectory() as root:
236
+ self.write_texture_pack(root)
237
+ html = self.write_html(
238
+ root,
239
+ '<deck-stage><section data-pptx-layout="cover">'
240
+ '<img src="assets/generated/replacement.webp"></section></deck-stage>',
241
+ )
242
+
243
+ problems = validate_layout_assets(root, html, 'assets/pptx-claude')
244
+
245
+ self.assertEqual(1, len(problems))
246
+ self.assertIn('cover', problems[0])
247
+ self.assertIn('texture-1', problems[0])
248
+ self.assertIn('缺少固定实例', problems[0])
249
+
250
+ def test_layout_bound_texture_on_an_unowned_layout_is_rejected(self):
251
+ with tempfile.TemporaryDirectory() as root:
252
+ self.write_texture_pack(root)
253
+ html = self.write_html(
254
+ root,
255
+ '<deck-stage><section data-pptx-layout="content">'
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>',
259
+ )
260
+
261
+ problems = validate_layout_assets(root, html, 'assets/pptx-claude')
262
+
263
+ self.assertEqual(1, len(problems))
264
+ self.assertIn('content', problems[0])
265
+ self.assertIn('texture-1', problems[0])
266
+ self.assertIn('不得使用', problems[0])
267
+
268
+ def test_layout_must_use_its_bound_background_with_query_and_fragment(self):
269
+ with tempfile.TemporaryDirectory() as root:
270
+ self.write_background_pack(root)
271
+ html = self.write_html(
272
+ root,
273
+ '<deck-stage><section data-pptx-layout="content" '
274
+ 'data-pptx-asset="bg-content-1" '
275
+ 'style="background-image:url('
276
+ '\'assets/pptx-claude/backgrounds/content.webp?v=2#slide\')">'
277
+ '正文</section></deck-stage>',
278
+ )
279
+
280
+ self.assertEqual(
281
+ [],
282
+ validate_layout_assets(root, html, 'assets/pptx-claude'),
283
+ )
284
+
285
+ def test_repeated_asset_slots_require_repeated_placements(self):
286
+ with tempfile.TemporaryDirectory() as root:
287
+ self.write_texture_pack(root)
288
+ with open(os.path.join(root, 'layouts.md'), 'w', encoding='utf-8') as stream:
289
+ stream.write(
290
+ '---\n'
291
+ 'canvas: 1920x1080\n'
292
+ 'layouts:\n'
293
+ ' cover:\n'
294
+ ' role: cover\n'
295
+ ' slots:\n'
296
+ ' - {role: texture, box: [0, 0, 100, 100], type: pic, asset: texture-1}\n'
297
+ ' - {role: texture, box: [1800, 980, 100, 100], type: pic, asset: texture-1}\n'
298
+ '---\n'
299
+ )
300
+ html = self.write_html(
301
+ root,
302
+ '<deck-stage><section data-pptx-layout="cover">'
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>',
306
+ )
307
+
308
+ problems = validate_layout_assets(root, html, 'assets/pptx-claude')
309
+
310
+ self.assertEqual(1, len(problems))
311
+ self.assertIn('位置尺寸应为 [1800, 980, 100, 100]', problems[0])
312
+
313
+ def test_legacy_logo_scope_entrypoint_keeps_failure_exit_code(self):
314
+ with tempfile.TemporaryDirectory() as root:
315
+ self.write_texture_pack(root)
316
+ html = self.write_html(
317
+ root,
318
+ '<deck-stage><section data-pptx-layout="cover">'
319
+ '<img src="assets/generated/replacement.webp"></section></deck-stage>',
320
+ )
321
+
322
+ result = subprocess.run(
323
+ [
324
+ sys.executable,
325
+ os.path.join(os.path.dirname(__file__), 'verify_logo_scope.py'),
326
+ root,
327
+ html,
328
+ '--asset-prefix',
329
+ 'assets/pptx-claude',
330
+ ],
331
+ capture_output=True,
332
+ check=False,
333
+ text=True,
334
+ )
335
+
336
+ self.assertEqual(1, result.returncode)
337
+ self.assertIn('PPTX_LAYOUT_ASSETS: FAIL', result.stdout)
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'),
144
475
  )
145
476
 
146
477
 
@@ -0,0 +1,400 @@
1
+ #!/usr/bin/env python3
2
+ """Verify that generated slides honor every asset bound to their PPTX layout."""
3
+ import argparse
4
+ import hashlib
5
+ import os
6
+ import posixpath
7
+ import re
8
+ import sys
9
+ from collections import Counter
10
+ from html.parser import HTMLParser
11
+ from urllib.parse import urlsplit
12
+
13
+ from check_v2 import Pack
14
+
15
+
16
+ URL_RE = re.compile(r'url\(\s*[\'"]?([^\'")\s]+)', re.I)
17
+ VOID_TAGS = {
18
+ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
19
+ 'link', 'meta', 'param', 'source', 'track', 'wbr',
20
+ }
21
+
22
+
23
+ def normalized_path(value):
24
+ path = urlsplit(value).path.replace('\\', '/')
25
+ return posixpath.normpath(path).lstrip('./')
26
+
27
+
28
+ def bound_asset_ids(value, known_asset_ids):
29
+ found = Counter()
30
+ if isinstance(value, dict):
31
+ for key, child in value.items():
32
+ if (key in ('asset', 'background')
33
+ and isinstance(child, str)
34
+ and child in known_asset_ids):
35
+ found[child] += 1
36
+ found.update(bound_asset_ids(child, known_asset_ids))
37
+ elif isinstance(value, list):
38
+ for child in value:
39
+ found.update(bound_asset_ids(child, known_asset_ids))
40
+ return found
41
+
42
+
43
+ def layout_asset_contract(pack):
44
+ known_asset_ids = set(pack.assets)
45
+ owners = {}
46
+ for layout_name, (layout, _) in pack.layouts.items():
47
+ for asset_id, _, _ in layout_asset_instances(
48
+ layout, known_asset_ids, pack.canvas):
49
+ owners.setdefault(asset_id, set()).add(layout_name)
50
+ return 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.setdefault(posixpath.join(prefix, relative), set()).add(asset_id)
67
+ return urls
68
+
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
+
178
+ def urls_from_attrs(attrs):
179
+ urls = []
180
+ for key, value in attrs:
181
+ if not value:
182
+ continue
183
+ if key.lower() in ('src', 'href'):
184
+ urls.append(value)
185
+ elif key.lower() == 'srcset':
186
+ urls.extend(item.strip().split(' ', 1)[0] for item in value.split(','))
187
+ elif key.lower() == 'style':
188
+ urls.extend(URL_RE.findall(value))
189
+ return urls
190
+
191
+
192
+ class SlideAssetParser(HTMLParser):
193
+ def __init__(self):
194
+ super().__init__()
195
+ self.slides = []
196
+ self._stack = []
197
+ self._style_depth = 0
198
+ self.outside_urls = []
199
+
200
+ def handle_starttag(self, tag, attrs):
201
+ tag = tag.lower()
202
+ attrs_map = {key.lower(): value for key, value in attrs}
203
+ urls = urls_from_attrs(attrs)
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
227
+ )
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:
240
+ self.outside_urls.extend(urls)
241
+ if tag == 'style':
242
+ self._style_depth += 1
243
+ if tag not in VOID_TAGS:
244
+ self._stack.append({
245
+ 'hidden': hidden,
246
+ 'slide': slide,
247
+ 'tag': tag,
248
+ })
249
+
250
+ def handle_startendtag(self, tag, attrs):
251
+ self.handle_starttag(tag, attrs)
252
+ if tag.lower() not in VOID_TAGS:
253
+ self.handle_endtag(tag)
254
+
255
+ def handle_endtag(self, tag):
256
+ tag = tag.lower()
257
+ if tag == 'style' and self._style_depth:
258
+ self._style_depth -= 1
259
+ if tag not in VOID_TAGS and self._stack:
260
+ self._stack.pop()
261
+
262
+ def handle_data(self, data):
263
+ if not self._style_depth:
264
+ return
265
+ self.outside_urls.extend(URL_RE.findall(data))
266
+
267
+
268
+ def validate_layout_assets(pack_dir, html_path, asset_prefix):
269
+ """Return violations of the asset contract declared by each layout."""
270
+ pack = Pack(pack_dir)
271
+ owners = layout_asset_contract(pack)
272
+ known_urls = asset_urls(pack, asset_prefix, owners)
273
+ if not known_urls:
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
+ }
280
+
281
+ with open(html_path, encoding='utf-8') as stream:
282
+ text = stream.read()
283
+ parser = SlideAssetParser()
284
+ parser.feed(text)
285
+ parser.close()
286
+
287
+ problems = copied_asset_problems(pack, html_path, asset_prefix, owners)
288
+ for url in parser.outside_urls:
289
+ for asset_id in sorted(known_urls.get(normalized_path(url), ())):
290
+ problems.append(
291
+ '模板资产 %s 出现在 slide section 外,无法核验页型归属' % asset_id)
292
+ for number, slide in enumerate(parser.slides, 1):
293
+ layout = slide['layout']
294
+ if not layout:
295
+ problems.append('第 %d 页缺少 data-pptx-layout,无法核验模板资产归属' % number)
296
+ continue
297
+ if layout not in pack.layouts:
298
+ problems.append('第 %d 页声明了不存在的模板页型: %s' % (number, layout))
299
+ continue
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)
344
+ for asset_id in sorted(used_asset_counts):
345
+ if layout not in owners.get(asset_id, set()):
346
+ allowed = '、'.join(sorted(owners.get(asset_id) or ())) or '(无)'
347
+ problems.append(
348
+ '第 %d 页页型 %s 不得使用 %s;只允许: %s'
349
+ % (number, layout, asset_id, allowed))
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()):
375
+ problems.append(
376
+ '第 %d 页页型 %s 额外使用了固定实例 %s'
377
+ % (number, layout, instance['asset']))
378
+ return problems
379
+
380
+
381
+ def main(argv=None):
382
+ parser = argparse.ArgumentParser(
383
+ description='Verify that generated deck HTML honors PPTX layout asset bindings.')
384
+ parser.add_argument('pack_dir')
385
+ parser.add_argument('html_path')
386
+ parser.add_argument('--asset-prefix', required=True)
387
+ args = parser.parse_args(argv)
388
+
389
+ problems = validate_layout_assets(args.pack_dir, args.html_path, args.asset_prefix)
390
+ if not problems:
391
+ print('PPTX_LAYOUT_ASSETS: PASS')
392
+ return 0
393
+ print('PPTX_LAYOUT_ASSETS: FAIL count=%d' % len(problems))
394
+ for problem in problems:
395
+ print('[layoutAssets] %s' % problem)
396
+ return 1
397
+
398
+
399
+ if __name__ == '__main__':
400
+ sys.exit(main())
@@ -1,187 +1,11 @@
1
1
  #!/usr/bin/env python3
2
- """Verify that generated slides use template logos only on owning archetypes."""
3
- import argparse
4
- import posixpath
5
- import re
2
+ """Backward-compatible entrypoint for PPTX layout asset verification."""
6
3
  import sys
7
- from html.parser import HTMLParser
8
- from urllib.parse import urlsplit
9
4
 
10
- from check_v2 import Pack
5
+ from verify_layout_assets import main, validate_layout_assets
11
6
 
12
7
 
13
- URL_RE = re.compile(r'url\(\s*[\'"]?([^\'")\s]+)', re.I)
14
- VOID_TAGS = {
15
- 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
16
- 'link', 'meta', 'param', 'source', 'track', 'wbr',
17
- }
18
-
19
-
20
- def normalized_path(value):
21
- path = urlsplit(value).path.replace('\\', '/')
22
- return posixpath.normpath(path).lstrip('./')
23
-
24
-
25
- def asset_urls(pack, asset_prefix):
26
- prefix = normalized_path(asset_prefix).rstrip('/')
27
- urls = {}
28
- for asset_id, (entry, _) in pack.assets.items():
29
- if not isinstance(entry, dict) or entry.get('kind') != 'logo':
30
- continue
31
- path = entry.get('path')
32
- if not isinstance(path, str) or not path:
33
- continue
34
- relative = normalized_path(path)
35
- if relative.startswith('assets/'):
36
- relative = relative[len('assets/'):]
37
- urls[posixpath.join(prefix, relative)] = asset_id
38
- return urls
39
-
40
-
41
- def contains_asset(value, asset_id):
42
- if isinstance(value, dict):
43
- return (value.get('asset') == asset_id
44
- or any(contains_asset(child, asset_id) for child in value.values()))
45
- if isinstance(value, list):
46
- return any(contains_asset(child, asset_id) for child in value)
47
- return False
48
-
49
-
50
- def logo_owners(pack):
51
- owners = {}
52
- for asset_url, asset_id in asset_urls(pack, '').items():
53
- del asset_url
54
- owners[asset_id] = {
55
- name
56
- for name, (layout, _) in pack.layouts.items()
57
- if contains_asset(layout, asset_id)
58
- }
59
- return owners
60
-
61
-
62
- def urls_from_attrs(attrs):
63
- urls = []
64
- for key, value in attrs:
65
- if not value:
66
- continue
67
- if key.lower() in ('src', 'href'):
68
- urls.append(value)
69
- elif key.lower() == 'srcset':
70
- urls.extend(item.strip().split(' ', 1)[0] for item in value.split(','))
71
- elif key.lower() == 'style':
72
- urls.extend(URL_RE.findall(value))
73
- return urls
74
-
75
-
76
- class SlideAssetParser(HTMLParser):
77
- def __init__(self):
78
- super().__init__()
79
- self.slides = []
80
- self._tags = []
81
- self._sections = []
82
- self._active_slides = []
83
- self._style_depth = 0
84
- self.outside_urls = []
85
-
86
- def handle_starttag(self, tag, attrs):
87
- tag = tag.lower()
88
- urls = urls_from_attrs(attrs)
89
- parent = self._tags[-1] if self._tags else None
90
- if tag == 'section':
91
- slide = (
92
- {'layout': dict(attrs).get('data-pptx-layout'), 'urls': urls}
93
- if parent == 'deck-stage' else None
94
- )
95
- self._sections.append(slide)
96
- if slide is not None:
97
- self._active_slides.append(slide)
98
- elif self._active_slides:
99
- self._active_slides[-1]['urls'].extend(urls)
100
- else:
101
- self.outside_urls.extend(urls)
102
- if tag == 'style':
103
- self._style_depth += 1
104
- if tag not in VOID_TAGS:
105
- self._tags.append(tag)
106
-
107
- def handle_startendtag(self, tag, attrs):
108
- if self._active_slides:
109
- self._active_slides[-1]['urls'].extend(urls_from_attrs(attrs))
110
- else:
111
- self.outside_urls.extend(urls_from_attrs(attrs))
112
-
113
- def handle_endtag(self, tag):
114
- tag = tag.lower()
115
- if tag == 'style' and self._style_depth:
116
- self._style_depth -= 1
117
- if tag == 'section' and self._sections:
118
- slide = self._sections.pop()
119
- if slide is not None:
120
- self.slides.append(slide)
121
- self._active_slides.pop()
122
- if tag not in VOID_TAGS and self._tags:
123
- self._tags.pop()
124
-
125
- def handle_data(self, data):
126
- if not self._style_depth:
127
- return
128
- self.outside_urls.extend(URL_RE.findall(data))
129
-
130
-
131
- def validate_logo_scope(pack_dir, html_path, asset_prefix):
132
- """Return every template-logo placement that does not match the slide archetype."""
133
- pack = Pack(pack_dir)
134
- known_urls = asset_urls(pack, asset_prefix)
135
- owners = logo_owners(pack)
136
- if not known_urls:
137
- return []
138
-
139
- with open(html_path, encoding='utf-8') as stream:
140
- text = stream.read()
141
- parser = SlideAssetParser()
142
- parser.feed(text)
143
- parser.close()
144
-
145
- problems = []
146
- for url in parser.outside_urls:
147
- asset_id = known_urls.get(normalized_path(url))
148
- if asset_id:
149
- problems.append(
150
- '模板 logo %s 出现在 slide section 外,无法核验页型归属' % asset_id)
151
- for number, slide in enumerate(parser.slides, 1):
152
- layout = slide['layout']
153
- if not layout:
154
- problems.append('第 %d 页缺少 data-pptx-layout,无法核验模板 logo 归属' % number)
155
- continue
156
- if layout not in pack.layouts:
157
- problems.append('第 %d 页声明了不存在的模板页型: %s' % (number, layout))
158
- continue
159
- for url in slide['urls']:
160
- asset_id = known_urls.get(normalized_path(url))
161
- if asset_id and layout not in owners.get(asset_id, set()):
162
- allowed = '、'.join(sorted(owners.get(asset_id) or ())) or '(无)'
163
- problems.append(
164
- '第 %d 页页型 %s 不得使用 %s;只允许: %s'
165
- % (number, layout, asset_id, allowed))
166
- return problems
167
-
168
-
169
- def main(argv=None):
170
- parser = argparse.ArgumentParser(
171
- description='Verify that generated deck HTML places PPTX logos only on owning layouts.')
172
- parser.add_argument('pack_dir')
173
- parser.add_argument('html_path')
174
- parser.add_argument('--asset-prefix', required=True)
175
- args = parser.parse_args(argv)
176
-
177
- problems = validate_logo_scope(args.pack_dir, args.html_path, args.asset_prefix)
178
- if not problems:
179
- print('PPTX_LOGO_SCOPE: PASS')
180
- return 0
181
- print('PPTX_LOGO_SCOPE: FAIL count=%d' % len(problems))
182
- for problem in problems:
183
- print('[logoScope] %s' % problem)
184
- return 1
8
+ validate_logo_scope = validate_layout_assets
185
9
 
186
10
 
187
11
  if __name__ == '__main__':