@lark-apaas/coding-steering 0.1.32-dev.4f80f68 → 0.1.32-dev.942e73f
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 +27 -18
- package/steering/design-html/skills/pptx-style-extract/scripts/census.py +8 -2
- package/steering/design-html/skills/pptx-style-extract/scripts/draft.py +1152 -178
- package/steering/design-html/skills/pptx-style-extract/scripts/extract.py +229 -10
- package/steering/design-html/skills/pptx-style-extract/scripts/ooxml.py +18 -1
- package/steering/design-html/skills/pptx-style-extract/scripts/package.py +643 -40
- package/steering/design-html/skills/pptx-style-extract/scripts/parts.py +19 -3
- package/steering/design-html/skills/pptx-style-extract/scripts/render_pages.py +4 -2
- package/steering/design-html/skills/pptx-style-extract/scripts/test_asset_judgment_package.py +556 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/test_background_composite.py +308 -1
- package/steering/design-html/skills/pptx-style-extract/scripts/test_design_consumer_contract.py +6 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/test_flow_layout_contract.py +157 -7
- package/steering/design-html/skills/pptx-style-extract/scripts/test_layout_css.py +1519 -2
- package/steering/design-html/skills/pptx-style-extract/scripts/test_logo_scope.py +148 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/test_text_role_contract.py +151 -4
- package/steering/design-html/skills/pptx-style-extract/scripts/verify_logo_scope.py +188 -0
- package/steering/design-html/skills/pptx-style-extract/v2-format-spec.md +1 -1
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Regression tests for template logo placement in generated deck HTML."""
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
import tempfile
|
|
6
|
+
import unittest
|
|
7
|
+
|
|
8
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
9
|
+
|
|
10
|
+
from verify_logo_scope import validate_logo_scope
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class LogoScopeTest(unittest.TestCase):
|
|
14
|
+
def write_pack(self, root):
|
|
15
|
+
with open(os.path.join(root, 'design.md'), 'w', encoding='utf-8') as stream:
|
|
16
|
+
stream.write(
|
|
17
|
+
'---\n'
|
|
18
|
+
'assets:\n'
|
|
19
|
+
' logo-1:\n'
|
|
20
|
+
' path: assets/logos/1.png\n'
|
|
21
|
+
' kind: logo\n'
|
|
22
|
+
'layouts: layouts.md\n'
|
|
23
|
+
'---\n'
|
|
24
|
+
)
|
|
25
|
+
with open(os.path.join(root, 'layouts.md'), 'w', encoding='utf-8') as stream:
|
|
26
|
+
stream.write(
|
|
27
|
+
'---\n'
|
|
28
|
+
'canvas: 1920x1080\n'
|
|
29
|
+
'layouts:\n'
|
|
30
|
+
' cover:\n'
|
|
31
|
+
' role: cover\n'
|
|
32
|
+
' slots:\n'
|
|
33
|
+
' - {role: logo, box: [64, 64, 224, 48], type: pic, asset: logo-1}\n'
|
|
34
|
+
' content:\n'
|
|
35
|
+
' role: content\n'
|
|
36
|
+
' slots:\n'
|
|
37
|
+
' - {role: title, box: [100, 100, 800, 100], type: title}\n'
|
|
38
|
+
' closing:\n'
|
|
39
|
+
' role: closing\n'
|
|
40
|
+
' slots:\n'
|
|
41
|
+
' - {role: logo, box: [64, 64, 224, 48], type: pic, asset: logo-1}\n'
|
|
42
|
+
'---\n'
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def write_html(self, root, content):
|
|
46
|
+
path = os.path.join(root, 'index.html')
|
|
47
|
+
with open(path, 'w', encoding='utf-8') as stream:
|
|
48
|
+
stream.write(content)
|
|
49
|
+
return path
|
|
50
|
+
|
|
51
|
+
def test_logo_is_limited_to_every_archetype_with_its_slot(self):
|
|
52
|
+
with tempfile.TemporaryDirectory() as root:
|
|
53
|
+
self.write_pack(root)
|
|
54
|
+
html = self.write_html(
|
|
55
|
+
root,
|
|
56
|
+
'<deck-stage>'
|
|
57
|
+
'<section data-pptx-layout="cover">'
|
|
58
|
+
'<img src="assets/pptx-volcengine/logos/1.png"></section>'
|
|
59
|
+
'<section data-pptx-layout="content"><h1>正文</h1></section>'
|
|
60
|
+
'<section data-pptx-layout="closing">'
|
|
61
|
+
'<img src="assets/pptx-volcengine/logos/1.png"></section>'
|
|
62
|
+
'</deck-stage>',
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
self.assertEqual(
|
|
66
|
+
[],
|
|
67
|
+
validate_logo_scope(root, html, 'assets/pptx-volcengine'),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
def test_logo_on_an_unowned_archetype_is_rejected(self):
|
|
71
|
+
with tempfile.TemporaryDirectory() as root:
|
|
72
|
+
self.write_pack(root)
|
|
73
|
+
html = self.write_html(
|
|
74
|
+
root,
|
|
75
|
+
'<deck-stage>'
|
|
76
|
+
'<section data-pptx-layout="content">'
|
|
77
|
+
'<img src="assets/pptx-volcengine/logos/1.png"></section>'
|
|
78
|
+
'</deck-stage>',
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
problems = validate_logo_scope(root, html, 'assets/pptx-volcengine')
|
|
82
|
+
|
|
83
|
+
self.assertEqual(1, len(problems))
|
|
84
|
+
self.assertIn('content', problems[0])
|
|
85
|
+
self.assertIn('logo-1', problems[0])
|
|
86
|
+
|
|
87
|
+
def test_nested_content_section_does_not_become_a_slide(self):
|
|
88
|
+
with tempfile.TemporaryDirectory() as root:
|
|
89
|
+
self.write_pack(root)
|
|
90
|
+
html = self.write_html(
|
|
91
|
+
root,
|
|
92
|
+
'<deck-stage><section data-pptx-layout="cover">'
|
|
93
|
+
'<section class="content"><img src="assets/pptx-volcengine/logos/1.png">'
|
|
94
|
+
'</section></section></deck-stage>',
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
self.assertEqual(
|
|
98
|
+
[],
|
|
99
|
+
validate_logo_scope(root, html, 'assets/pptx-volcengine'),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def test_each_slide_declares_its_template_archetype(self):
|
|
103
|
+
with tempfile.TemporaryDirectory() as root:
|
|
104
|
+
self.write_pack(root)
|
|
105
|
+
html = self.write_html(
|
|
106
|
+
root,
|
|
107
|
+
'<deck-stage><section><h1>未声明页型</h1></section></deck-stage>',
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
self.assertEqual(
|
|
111
|
+
['第 1 页缺少 data-pptx-layout,无法核验模板 logo 归属'],
|
|
112
|
+
validate_logo_scope(root, html, 'assets/pptx-volcengine'),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def test_logo_in_global_css_is_rejected(self):
|
|
116
|
+
with tempfile.TemporaryDirectory() as root:
|
|
117
|
+
self.write_pack(root)
|
|
118
|
+
html = self.write_html(
|
|
119
|
+
root,
|
|
120
|
+
'<style>.slide-logo { background-image: url('
|
|
121
|
+
'"assets/pptx-volcengine/logos/1.png") }</style>'
|
|
122
|
+
'<deck-stage><section data-pptx-layout="content">正文</section></deck-stage>',
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
self.assertEqual(
|
|
126
|
+
['模板 logo logo-1 出现在 slide section 外,无法核验页型归属'],
|
|
127
|
+
validate_logo_scope(root, html, 'assets/pptx-volcengine'),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
def test_logo_in_a_slide_style_block_is_rejected(self):
|
|
131
|
+
with tempfile.TemporaryDirectory() as root:
|
|
132
|
+
self.write_pack(root)
|
|
133
|
+
html = self.write_html(
|
|
134
|
+
root,
|
|
135
|
+
'<deck-stage><section data-pptx-layout="content"><style>'
|
|
136
|
+
'.slide-logo { background-image: url('
|
|
137
|
+
'"assets/pptx-volcengine/logos/1.png") }</style>'
|
|
138
|
+
'正文</section></deck-stage>',
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
self.assertEqual(
|
|
142
|
+
['模板 logo logo-1 出现在 slide section 外,无法核验页型归属'],
|
|
143
|
+
validate_logo_scope(root, html, 'assets/pptx-volcengine'),
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
if __name__ == '__main__':
|
|
148
|
+
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('
|
|
146
|
+
self.assertIn('默认均为 body', draft)
|
|
146
147
|
self.assertIn(
|
|
147
|
-
'layout-1-text-1
|
|
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
|
-
'
|
|
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,188 @@
|
|
|
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
|
|
6
|
+
import sys
|
|
7
|
+
from html.parser import HTMLParser
|
|
8
|
+
from urllib.parse import urlsplit
|
|
9
|
+
|
|
10
|
+
from check_v2 import Pack
|
|
11
|
+
|
|
12
|
+
|
|
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
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
if __name__ == '__main__':
|
|
188
|
+
sys.exit(main())
|
|
@@ -133,7 +133,7 @@ layouts:
|
|
|
133
133
|
|
|
134
134
|
- **`background` 三形态**:`<asset-id>` / `{<theme>: <asset-id>}` / `{color: <colors-token>}`(`color` 是保留键,主题名禁止叫 color)。`asset` 两形态:`<asset-id>` / `{<theme>: <asset-id>}`。
|
|
135
135
|
- **背景安全扩展**:有真实背景图的 archetype 建议写 `text_safe: [x,y,w,h]`、`avoid: [{box: [x,y,w,h], reason: "..."}]`、`pairing_rule: "..."`。这些是消费约束,不参与封闭枚举;用于避免标题、正文、图表、卡片、表格、时间线及其容器外接矩形覆盖背景视觉主体、强光斑或深色透明区;透明容器也不能跨进禁放区。
|
|
136
|
-
- **流式页型**:内容长度会变化的内容页可用 `flow.regions` 表达纵向区带。`stack` 表达单列顺序,`grid` 表达并列列组,`free` 中的 item 必须带 `box`,用于 logo、页码、页眉和页脚等固定锚点。并列卡片可在 `grid.items` 中使用一层 `{role: group, css, gap, items}`:group 的 `css` 是卡片容器样式,内部 `items` 按顺序排布;不继续嵌套 group
|
|
136
|
+
- **流式页型**:内容长度会变化的内容页可用 `flow.regions` 表达纵向区带。`stack` 表达单列顺序,`grid` 表达并列列组,`free` 中的 item 必须带 `box`,用于 logo、页码、页眉和页脚等固定锚点。并列卡片可在 `grid.items` 中使用一层 `{role: group, css, gap, items}`:group 的 `css` 是卡片容器样式,内部 `items` 按顺序排布;不继续嵌套 group。区带可带自己的 `margin: [左, 右]`,覆盖 `flow` 整块的 `margin`(居中卡片组和贴左标题横向范围本就不同);不带则继承整块 `margin`。纵向位置与留白由消费模型结合实际内容决定,不把样张的 `y` 坐标当作流式硬约束。
|
|
137
137
|
- **`decor`(可选)**:这一页无文字的图形骨架——图标托底的圆、卡片、分隔线。每条 `{box, geom, css}`:`box` 定位,`css` 是可直接写进 style 的声明串,`geom` 取源形状的 prst(`ellipse` 另加 `border-radius: 50%`)。圆角以每条 `css` 为准,没有 `border-radius` 就按 `0`;不得因 `geom: roundRect` 自行补圆角,因为 OOXML 的 roundRect 可以有零圆角调节点。层级在背景之上、`slots` 之下;带 `asset` 的槽落在 decor 之上是版式本意,不算重叠。
|
|
138
138
|
- **slot 样式契约**:`box` 只承载 `[x,y,w,h]` 几何;可渲染属性统一放进 `css`,并可直接写入 HTML `style`。PPTX `bodyPr.insets_px` 转成 `box-sizing: border-box; padding: ...`,字号/字重/颜色/水平与垂直对齐/行高/字距/旋转分别转成标准 CSS。禁止在 slot 中输出 `size` / `weight` / `color` / `align` / `valign` / `insets_px` 等旧字段。
|
|
139
139
|
- **文本角色判断**:脚本把实例页及其引用版式中的现有文本槽、几何和 CSS 完整写入草案;`text_roles` 只供模型把这些槽判断为 `title | subtitle | header | footer | body`,不控制槽位去留。判断不清时用 `body`,不归纳模板中不存在的标题、页眉或页脚。
|