@lark-apaas/coding-steering 0.1.32-dev.a87aa13 → 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 +28 -19
- package/steering/design-html/skills/pptx-style-extract/scripts/census.py +8 -2
- package/steering/design-html/skills/pptx-style-extract/scripts/draft.py +1181 -190
- 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 +14 -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 +1598 -2
- package/steering/design-html/skills/pptx-style-extract/scripts/test_logo_scope.py +479 -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_layout_assets.py +400 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/verify_logo_scope.py +12 -0
- package/steering/design-html/skills/pptx-style-extract/v2-format-spec.md +1 -1
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Regression tests for template asset placement in generated deck HTML."""
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import tempfile
|
|
8
|
+
import unittest
|
|
9
|
+
|
|
10
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
11
|
+
|
|
12
|
+
from verify_layout_assets import validate_layout_assets
|
|
13
|
+
|
|
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
|
+
|
|
25
|
+
def write_pack(self, root):
|
|
26
|
+
self.write_asset(
|
|
27
|
+
root,
|
|
28
|
+
'logos/1.png',
|
|
29
|
+
b'logo',
|
|
30
|
+
'assets/pptx-volcengine',
|
|
31
|
+
)
|
|
32
|
+
with open(os.path.join(root, 'design.md'), 'w', encoding='utf-8') as stream:
|
|
33
|
+
stream.write(
|
|
34
|
+
'---\n'
|
|
35
|
+
'assets:\n'
|
|
36
|
+
' logo-1:\n'
|
|
37
|
+
' path: assets/logos/1.png\n'
|
|
38
|
+
' kind: logo\n'
|
|
39
|
+
'layouts: layouts.md\n'
|
|
40
|
+
'---\n'
|
|
41
|
+
)
|
|
42
|
+
with open(os.path.join(root, 'layouts.md'), 'w', encoding='utf-8') as stream:
|
|
43
|
+
stream.write(
|
|
44
|
+
'---\n'
|
|
45
|
+
'canvas: 1920x1080\n'
|
|
46
|
+
'layouts:\n'
|
|
47
|
+
' cover:\n'
|
|
48
|
+
' role: cover\n'
|
|
49
|
+
' slots:\n'
|
|
50
|
+
' - {role: logo, box: [64, 64, 224, 48], type: pic, asset: logo-1}\n'
|
|
51
|
+
' content:\n'
|
|
52
|
+
' role: content\n'
|
|
53
|
+
' slots:\n'
|
|
54
|
+
' - {role: title, box: [100, 100, 800, 100], type: title}\n'
|
|
55
|
+
' closing:\n'
|
|
56
|
+
' role: closing\n'
|
|
57
|
+
' slots:\n'
|
|
58
|
+
' - {role: logo, box: [64, 64, 224, 48], type: pic, asset: logo-1}\n'
|
|
59
|
+
'---\n'
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def write_html(self, root, content):
|
|
63
|
+
path = os.path.join(root, 'index.html')
|
|
64
|
+
with open(path, 'w', encoding='utf-8') as stream:
|
|
65
|
+
stream.write(content)
|
|
66
|
+
return path
|
|
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
|
+
|
|
131
|
+
def test_logo_is_limited_to_every_archetype_with_its_slot(self):
|
|
132
|
+
with tempfile.TemporaryDirectory() as root:
|
|
133
|
+
self.write_pack(root)
|
|
134
|
+
html = self.write_html(
|
|
135
|
+
root,
|
|
136
|
+
'<deck-stage>'
|
|
137
|
+
'<section data-pptx-layout="cover">'
|
|
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>'
|
|
141
|
+
'<section data-pptx-layout="content"><h1>正文</h1></section>'
|
|
142
|
+
'<section data-pptx-layout="closing">'
|
|
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>'
|
|
146
|
+
'</deck-stage>',
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
self.assertEqual(
|
|
150
|
+
[],
|
|
151
|
+
validate_layout_assets(root, html, 'assets/pptx-volcengine'),
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
def test_logo_on_an_unowned_archetype_is_rejected(self):
|
|
155
|
+
with tempfile.TemporaryDirectory() as root:
|
|
156
|
+
self.write_pack(root)
|
|
157
|
+
html = self.write_html(
|
|
158
|
+
root,
|
|
159
|
+
'<deck-stage>'
|
|
160
|
+
'<section data-pptx-layout="content">'
|
|
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>'
|
|
164
|
+
'</deck-stage>',
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
problems = validate_layout_assets(root, html, 'assets/pptx-volcengine')
|
|
168
|
+
|
|
169
|
+
self.assertEqual(1, len(problems))
|
|
170
|
+
self.assertIn('content', problems[0])
|
|
171
|
+
self.assertIn('logo-1', problems[0])
|
|
172
|
+
|
|
173
|
+
def test_nested_content_section_does_not_become_a_slide(self):
|
|
174
|
+
with tempfile.TemporaryDirectory() as root:
|
|
175
|
+
self.write_pack(root)
|
|
176
|
+
html = self.write_html(
|
|
177
|
+
root,
|
|
178
|
+
'<deck-stage><section data-pptx-layout="cover">'
|
|
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">'
|
|
182
|
+
'</section></section></deck-stage>',
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
self.assertEqual(
|
|
186
|
+
[],
|
|
187
|
+
validate_layout_assets(root, html, 'assets/pptx-volcengine'),
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
def test_each_slide_declares_its_template_archetype(self):
|
|
191
|
+
with tempfile.TemporaryDirectory() as root:
|
|
192
|
+
self.write_pack(root)
|
|
193
|
+
html = self.write_html(
|
|
194
|
+
root,
|
|
195
|
+
'<deck-stage><section><h1>未声明页型</h1></section></deck-stage>',
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
self.assertEqual(
|
|
199
|
+
['第 1 页缺少 data-pptx-layout,无法核验模板资产归属'],
|
|
200
|
+
validate_layout_assets(root, html, 'assets/pptx-volcengine'),
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
def test_logo_in_global_css_is_rejected(self):
|
|
204
|
+
with tempfile.TemporaryDirectory() as root:
|
|
205
|
+
self.write_pack(root)
|
|
206
|
+
html = self.write_html(
|
|
207
|
+
root,
|
|
208
|
+
'<style>.slide-logo { background-image: url('
|
|
209
|
+
'"assets/pptx-volcengine/logos/1.png") }</style>'
|
|
210
|
+
'<deck-stage><section data-pptx-layout="content">正文</section></deck-stage>',
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
self.assertEqual(
|
|
214
|
+
['模板资产 logo-1 出现在 slide section 外,无法核验页型归属'],
|
|
215
|
+
validate_layout_assets(root, html, 'assets/pptx-volcengine'),
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
def test_logo_in_a_slide_style_block_is_rejected(self):
|
|
219
|
+
with tempfile.TemporaryDirectory() as root:
|
|
220
|
+
self.write_pack(root)
|
|
221
|
+
html = self.write_html(
|
|
222
|
+
root,
|
|
223
|
+
'<deck-stage><section data-pptx-layout="content"><style>'
|
|
224
|
+
'.slide-logo { background-image: url('
|
|
225
|
+
'"assets/pptx-volcengine/logos/1.png") }</style>'
|
|
226
|
+
'正文</section></deck-stage>',
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
self.assertEqual(
|
|
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'),
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
if __name__ == '__main__':
|
|
479
|
+
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()
|