@lark-apaas/coding-steering 0.1.18-dev.ee5404f → 0.1.18-dev.fb5e270
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/charts/SKILL.md +4 -0
- package/steering/design-html/skills/pptx-style-extract/SKILL.md +8 -8
- package/steering/design-html/skills/pptx-style-extract/scripts/draft.py +548 -108
- package/steering/design-html/skills/pptx-style-extract/scripts/ooxml.py +18 -1
- package/steering/design-html/skills/pptx-style-extract/scripts/package.py +235 -26
- package/steering/design-html/skills/pptx-style-extract/scripts/test_asset_judgment_package.py +161 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/test_color_contract.py +3 -1
- package/steering/design-html/skills/pptx-style-extract/scripts/test_design_consumer_contract.py +1 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/test_flow_layout_contract.py +468 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/test_layout_css.py +407 -2
- package/steering/design-html/skills/pptx-style-extract/scripts/test_text_role_contract.py +40 -0
- package/steering/design-html/skills/pptx-style-extract/v2-format-spec.md +4 -3
- package/steering/nestjs-react-fullstack/skills/coding-guide/SKILL.md +10 -0
- package/steering/nestjs-react-fullstack/skills/plugin-guide/SKILL.md +5 -3
- package/steering/nestjs-react-fullstack/skills_local/plugin-guide/SKILL.md +4 -0
- package/steering/vite-react/skills/plugin-guide/SKILL.md +3 -1
- package/steering/vite-react/skills/react-three-fiber/SKILL.md +4 -0
|
@@ -8,8 +8,13 @@ import unittest
|
|
|
8
8
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
9
9
|
|
|
10
10
|
from check_v2 import Pack, rule_v2_16 # noqa: E402
|
|
11
|
-
from draft import
|
|
12
|
-
|
|
11
|
+
from draft import (attach_leftover_image_marks, contact_sheets, draft_layouts, # noqa: E402
|
|
12
|
+
emit_layouts, emit_manifest, fullscreen_overlay_media,
|
|
13
|
+
has_small_image_cluster, needs_asset_judgment,
|
|
14
|
+
slide_image_marks, slot_style)
|
|
15
|
+
from package import (FONTSIZE_RE, Fail, apply_asset_decisions, # noqa: E402
|
|
16
|
+
build_layouts_md, resolve_asset_candidate_lines,
|
|
17
|
+
split_top_blocks)
|
|
13
18
|
|
|
14
19
|
|
|
15
20
|
class LayoutCssTest(unittest.TestCase):
|
|
@@ -93,6 +98,406 @@ class LayoutCssTest(unittest.TestCase):
|
|
|
93
98
|
self.assertEqual(len(result.fails), 1)
|
|
94
99
|
self.assertIn('旧样式键 align/size', result.fails[0])
|
|
95
100
|
|
|
101
|
+
def test_normautofit_fontscale_shrinks_emitted_font_size(self):
|
|
102
|
+
# 章节大号数字:160px 字号靠 normAutofit fontScale 0.9 装进 144px 的框。
|
|
103
|
+
# 不乘 fontScale,消费端拿到 160px,字比框高,渐变裁切把底部切成透明。
|
|
104
|
+
shape = {
|
|
105
|
+
'text': {
|
|
106
|
+
'bodyPr': {'anchor': 't', 'font_scale': 0.9, 'ln_spc_reduction': 0.1},
|
|
107
|
+
'lstStyle': {'lvl1pPr': {'sz_px': 160, 'lnSpc': {'mult': 1.0}}},
|
|
108
|
+
'paragraphs': [{'runs': [{'text': '01.'}]}],
|
|
109
|
+
},
|
|
110
|
+
}
|
|
111
|
+
style = slot_style(shape)
|
|
112
|
+
self.assertIn('font-size: 144px', style['css'])
|
|
113
|
+
self.assertNotIn('font-size: 160px', style['css'])
|
|
114
|
+
# lnSpcReduction 0.1 把 1.0*1.2 的行高压到 1.08
|
|
115
|
+
self.assertIn('line-height: 1.08', style['css'])
|
|
116
|
+
|
|
117
|
+
def test_missing_autofit_leaves_font_size_untouched(self):
|
|
118
|
+
# 无 normAutofit(或无 fontScale)时零影响:字号原样、行高不缩。
|
|
119
|
+
shape = {
|
|
120
|
+
'text': {
|
|
121
|
+
'bodyPr': {'anchor': 't'},
|
|
122
|
+
'lstStyle': {'lvl1pPr': {'sz_px': 160, 'lnSpc': {'mult': 1.0}}},
|
|
123
|
+
'paragraphs': [{'runs': [{'text': '01.'}]}],
|
|
124
|
+
},
|
|
125
|
+
}
|
|
126
|
+
style = slot_style(shape)
|
|
127
|
+
self.assertIn('font-size: 160px', style['css'])
|
|
128
|
+
self.assertIn('line-height: 1.2', style['css'])
|
|
129
|
+
|
|
130
|
+
def test_run_level_style_is_emitted_from_flat_ooxml_record(self):
|
|
131
|
+
# read_txbody 把 a:rPr 的属性直接展开到 run;没有嵌套 rPr 字段。
|
|
132
|
+
shape = {
|
|
133
|
+
'text': {
|
|
134
|
+
'bodyPr': {'anchor': 't'},
|
|
135
|
+
'paragraphs': [{
|
|
136
|
+
'runs': [{
|
|
137
|
+
'text': '品牌标题',
|
|
138
|
+
'sz_px': 52,
|
|
139
|
+
'weight': 700,
|
|
140
|
+
'latin': 'Brand Sans',
|
|
141
|
+
'color': {'resolved': '#C1121F'},
|
|
142
|
+
}],
|
|
143
|
+
}],
|
|
144
|
+
},
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
style = slot_style(shape)
|
|
148
|
+
|
|
149
|
+
self.assertIn('font-size: 52px', style['css'])
|
|
150
|
+
self.assertIn(
|
|
151
|
+
'font-family: "Brand Sans", "PingFang SC", "Microsoft YaHei", sans-serif',
|
|
152
|
+
style['css'],
|
|
153
|
+
)
|
|
154
|
+
self.assertIn('font-weight: 700', style['css'])
|
|
155
|
+
self.assertIn('color: #C1121F', style['css'])
|
|
156
|
+
|
|
157
|
+
def test_run_level_style_overrides_inherited_list_style(self):
|
|
158
|
+
shape = {
|
|
159
|
+
'text': {
|
|
160
|
+
'lstStyle': {
|
|
161
|
+
'lvl1pPr': {
|
|
162
|
+
'sz_px': 20,
|
|
163
|
+
'latin': 'Inherited Sans',
|
|
164
|
+
'color': {'resolved': '#111111'},
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
'paragraphs': [{
|
|
168
|
+
'runs': [{
|
|
169
|
+
'text': '直接格式',
|
|
170
|
+
'sz_px': 52,
|
|
171
|
+
'latin': 'Direct Sans',
|
|
172
|
+
'color': {'resolved': '#C1121F'},
|
|
173
|
+
}],
|
|
174
|
+
}],
|
|
175
|
+
},
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
style = slot_style(shape)
|
|
179
|
+
|
|
180
|
+
self.assertIn('font-size: 52px', style['css'])
|
|
181
|
+
self.assertIn('font-family: "Direct Sans"', style['css'])
|
|
182
|
+
self.assertIn('color: #C1121F', style['css'])
|
|
183
|
+
self.assertNotIn('font-size: 20px', style['css'])
|
|
184
|
+
self.assertNotIn('Inherited Sans', style['css'])
|
|
185
|
+
|
|
186
|
+
def test_asset_decisions_keep_content_slots_and_bind_texture_slots(self):
|
|
187
|
+
manifest = {
|
|
188
|
+
'assets': [],
|
|
189
|
+
'asset_decisions': [
|
|
190
|
+
{'source_media': 'chart.png', 'decision': 'content'},
|
|
191
|
+
{'source_media': 'ornament.png', 'decision': 'texture'},
|
|
192
|
+
],
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
decisions, asset_ids = apply_asset_decisions(manifest)
|
|
196
|
+
lines = resolve_asset_candidate_lines([
|
|
197
|
+
' - {role: asset-candidate, box: [10, 20, 300, 200], type: pic, '
|
|
198
|
+
'source_media: chart.png}',
|
|
199
|
+
' - {role: asset-candidate, box: [20, 30, 80, 80], type: pic, '
|
|
200
|
+
'source_media: ornament.png}',
|
|
201
|
+
], decisions, asset_ids)
|
|
202
|
+
|
|
203
|
+
self.assertEqual(len(lines), 2)
|
|
204
|
+
self.assertNotIn('chart.png', lines[0])
|
|
205
|
+
self.assertNotIn('source_media', lines[0])
|
|
206
|
+
self.assertNotIn('asset:', lines[0])
|
|
207
|
+
self.assertIn('role: pic', lines[0])
|
|
208
|
+
self.assertNotIn('source_media', lines[1])
|
|
209
|
+
self.assertIn('role: texture', lines[1])
|
|
210
|
+
self.assertIn('asset: texture-1', lines[1])
|
|
211
|
+
self.assertEqual(manifest['assets'], [{
|
|
212
|
+
'id': 'texture-1',
|
|
213
|
+
'source_media': 'ornament.png',
|
|
214
|
+
'kind': 'texture',
|
|
215
|
+
}])
|
|
216
|
+
|
|
217
|
+
def test_asset_decisions_do_not_reuse_an_existing_asset_id(self):
|
|
218
|
+
manifest = {
|
|
219
|
+
'assets': [
|
|
220
|
+
{
|
|
221
|
+
'id': 'logo-primary',
|
|
222
|
+
'source_media': 'candidate.png',
|
|
223
|
+
'kind': 'logo',
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
'id': 'texture-1',
|
|
227
|
+
'source_media': 'existing.png',
|
|
228
|
+
'kind': 'texture',
|
|
229
|
+
},
|
|
230
|
+
],
|
|
231
|
+
'asset_decisions': [
|
|
232
|
+
{'source_media': 'candidate.png', 'decision': 'texture'},
|
|
233
|
+
],
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
_, asset_ids = apply_asset_decisions(manifest)
|
|
237
|
+
|
|
238
|
+
self.assertEqual(asset_ids['existing.png'], 'texture-1')
|
|
239
|
+
self.assertEqual(asset_ids['candidate.png'], 'texture-2')
|
|
240
|
+
self.assertEqual(
|
|
241
|
+
[asset['id'] for asset in manifest['assets']],
|
|
242
|
+
['texture-2', 'texture-1'],
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
def test_asset_decisions_reject_candidates_without_layout_slots(self):
|
|
246
|
+
manifest = {
|
|
247
|
+
'assets': [],
|
|
248
|
+
'asset_decisions': [
|
|
249
|
+
{'source_media': 'orphan-decoration.png', 'decision': 'texture'},
|
|
250
|
+
],
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
with self.assertRaisesRegex(Fail, '没有对应图片槽'):
|
|
254
|
+
apply_asset_decisions(manifest, bound_sources=set())
|
|
255
|
+
|
|
256
|
+
def test_translucent_fullscreen_decoration_requires_model_judgment(self):
|
|
257
|
+
candidate = {
|
|
258
|
+
'file': 'glow.png',
|
|
259
|
+
'fullscreen': True,
|
|
260
|
+
'slides': [2],
|
|
261
|
+
'probe': {'alpha_mean': 80},
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
with tempfile.TemporaryDirectory() as output_dir:
|
|
265
|
+
emit_manifest({'theme_topology': {}}, [], [candidate], output_dir)
|
|
266
|
+
with open(os.path.join(output_dir, 'manifest.yaml'), encoding='utf-8') as stream:
|
|
267
|
+
manifest = stream.read()
|
|
268
|
+
|
|
269
|
+
self.assertIn('source_media: glow.png', manifest)
|
|
270
|
+
self.assertIn('decision: TODO-kind-1', manifest)
|
|
271
|
+
|
|
272
|
+
def test_deterministic_fullscreen_images_skip_model_judgment(self):
|
|
273
|
+
self.assertFalse(needs_asset_judgment({
|
|
274
|
+
'fullscreen': True,
|
|
275
|
+
'probe': {'alpha_mean': 255, 'near_blank': False},
|
|
276
|
+
}))
|
|
277
|
+
self.assertFalse(needs_asset_judgment({
|
|
278
|
+
'fullscreen': True,
|
|
279
|
+
'probe': {'alpha_mean': 5, 'near_blank': True},
|
|
280
|
+
}))
|
|
281
|
+
|
|
282
|
+
def test_shape_fill_images_are_exposed_as_slide_marks(self):
|
|
283
|
+
data = {
|
|
284
|
+
'images': [{
|
|
285
|
+
'media': 'ppt/media/fill.png',
|
|
286
|
+
'fullscreen': False,
|
|
287
|
+
'boxes': [{
|
|
288
|
+
'box': {'x': 120, 'y': 240, 'w': 360, 'h': 420},
|
|
289
|
+
'parts': ['ppt/slides/slide2.xml'],
|
|
290
|
+
}],
|
|
291
|
+
}],
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
marks = slide_image_marks(data)
|
|
295
|
+
|
|
296
|
+
self.assertEqual(marks['ppt/slides/slide2.xml'], [{
|
|
297
|
+
'media': 'ppt/media/fill.png',
|
|
298
|
+
'box': {'x': 120, 'y': 240, 'w': 360, 'h': 420},
|
|
299
|
+
}])
|
|
300
|
+
|
|
301
|
+
def test_form3_layouts_include_instance_image_fills(self):
|
|
302
|
+
layouts = []
|
|
303
|
+
shapes = []
|
|
304
|
+
for index in range(1, 4):
|
|
305
|
+
part = 'ppt/slideLayouts/slideLayout%d.xml' % index
|
|
306
|
+
layouts.append({
|
|
307
|
+
'part': part,
|
|
308
|
+
'name': 'Layout %d' % index,
|
|
309
|
+
'used_by_slides': 1,
|
|
310
|
+
})
|
|
311
|
+
shapes.append({
|
|
312
|
+
'part': part,
|
|
313
|
+
'layer': 'layout',
|
|
314
|
+
'kind': 'sp',
|
|
315
|
+
'box': {'x': 10, 'y': 10, 'w': 500, 'h': 100},
|
|
316
|
+
'ph': {'type': 'title', 'idx': str(index)},
|
|
317
|
+
'text': {'paragraphs': [{'runs': [{
|
|
318
|
+
'text': 'Title %d' % index,
|
|
319
|
+
'sz_px': 40,
|
|
320
|
+
}]}]},
|
|
321
|
+
})
|
|
322
|
+
data = {
|
|
323
|
+
'canvas': {'px': [1920, 1080]},
|
|
324
|
+
'form_hint': {'form': 3},
|
|
325
|
+
'layouts': layouts,
|
|
326
|
+
'images': [{
|
|
327
|
+
'media': 'ppt/media/fill.png',
|
|
328
|
+
'fullscreen': False,
|
|
329
|
+
'boxes': [{
|
|
330
|
+
'box': {'x': 120, 'y': 240, 'w': 360, 'h': 420},
|
|
331
|
+
'parts': ['ppt/slides/slide1.xml'],
|
|
332
|
+
}],
|
|
333
|
+
}],
|
|
334
|
+
'media': [],
|
|
335
|
+
'theme_topology': {
|
|
336
|
+
'themes': ['single'],
|
|
337
|
+
'per_master': [],
|
|
338
|
+
'default': 'single',
|
|
339
|
+
},
|
|
340
|
+
'reference_graph': {
|
|
341
|
+
'master_of_layout': {},
|
|
342
|
+
'layout_of_slide': {
|
|
343
|
+
'ppt/slides/slide1.xml': 'ppt/slideLayouts/slideLayout1.xml',
|
|
344
|
+
},
|
|
345
|
+
},
|
|
346
|
+
'background_composites': {},
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
with tempfile.TemporaryDirectory() as output_dir:
|
|
350
|
+
os.makedirs(os.path.join(output_dir, 'ref'))
|
|
351
|
+
with open(os.path.join(output_dir, 'ref', 'shapes.json'), 'w',
|
|
352
|
+
encoding='utf-8') as stream:
|
|
353
|
+
import json
|
|
354
|
+
json.dump({'shapes': shapes}, stream)
|
|
355
|
+
archetypes, _, _ = draft_layouts(data, output_dir)
|
|
356
|
+
|
|
357
|
+
self.assertTrue(any(
|
|
358
|
+
slot.get('media') == 'ppt/media/fill.png'
|
|
359
|
+
for archetype in archetypes
|
|
360
|
+
for slot in archetype['slots']
|
|
361
|
+
))
|
|
362
|
+
|
|
363
|
+
def test_shape_opacity_marks_opaque_fullscreen_media_as_overlay(self):
|
|
364
|
+
data = {
|
|
365
|
+
'media': [{
|
|
366
|
+
'media': 'ppt/media/overlay.png',
|
|
367
|
+
'out': 'media-out/overlay.png',
|
|
368
|
+
}],
|
|
369
|
+
'images': [{
|
|
370
|
+
'media': 'ppt/media/overlay.png',
|
|
371
|
+
'fullscreen': True,
|
|
372
|
+
}],
|
|
373
|
+
}
|
|
374
|
+
shapes = [{
|
|
375
|
+
'kind': 'pic',
|
|
376
|
+
'media': 'ppt/media/overlay.png',
|
|
377
|
+
'w_pct': 100,
|
|
378
|
+
'h_pct': 100,
|
|
379
|
+
'opacity': 0.25,
|
|
380
|
+
}]
|
|
381
|
+
|
|
382
|
+
with tempfile.TemporaryDirectory() as output_dir:
|
|
383
|
+
media_dir = os.path.join(output_dir, 'media-out')
|
|
384
|
+
os.makedirs(media_dir)
|
|
385
|
+
try:
|
|
386
|
+
from PIL import Image
|
|
387
|
+
except ImportError:
|
|
388
|
+
self.skipTest('Pillow unavailable')
|
|
389
|
+
Image.new('RGB', (16, 9), (20, 40, 60)).save(
|
|
390
|
+
os.path.join(media_dir, 'overlay.png'))
|
|
391
|
+
|
|
392
|
+
overlays = fullscreen_overlay_media(data, output_dir, shapes)
|
|
393
|
+
|
|
394
|
+
self.assertEqual(overlays, {'ppt/media/overlay.png'})
|
|
395
|
+
|
|
396
|
+
def test_page_with_multiple_small_images_is_kept_for_logo_wall_judgment(self):
|
|
397
|
+
page = {
|
|
398
|
+
'marks': [
|
|
399
|
+
{'media': 'ppt/media/logo-%d.png' % index,
|
|
400
|
+
'box': {'x': index * 180, 'y': 300, 'w': 120, 'h': 100}}
|
|
401
|
+
for index in range(3)
|
|
402
|
+
],
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
self.assertTrue(has_small_image_cluster([page], (1920, 1080)))
|
|
406
|
+
self.assertFalse(has_small_image_cluster([{
|
|
407
|
+
'marks': page['marks'][:2],
|
|
408
|
+
}], (1920, 1080)))
|
|
409
|
+
|
|
410
|
+
def test_single_image_orphan_reuses_nearest_layout_without_new_archetype(self):
|
|
411
|
+
archetypes = [{
|
|
412
|
+
'name': 'layout-1',
|
|
413
|
+
'slots': [],
|
|
414
|
+
'rep': 2,
|
|
415
|
+
'_source_layouts': ['ppt/slideLayouts/slideLayout1.xml'],
|
|
416
|
+
'_source_backgrounds': ['ppt/media/bg.png'],
|
|
417
|
+
'_text_n': 4,
|
|
418
|
+
}, {
|
|
419
|
+
'name': 'layout-2',
|
|
420
|
+
'slots': [],
|
|
421
|
+
'rep': 8,
|
|
422
|
+
'_source_layouts': ['ppt/slideLayouts/slideLayout2.xml'],
|
|
423
|
+
'_source_backgrounds': ['ppt/media/other-bg.png'],
|
|
424
|
+
'_text_n': 12,
|
|
425
|
+
}]
|
|
426
|
+
pages = [{
|
|
427
|
+
'part': 'ppt/slides/slide11.xml',
|
|
428
|
+
'no': 11,
|
|
429
|
+
'layout': 'ppt/slideLayouts/slideLayout1.xml',
|
|
430
|
+
'rendered_bg': 'ppt/media/bg.png',
|
|
431
|
+
'bg_media': None,
|
|
432
|
+
'bg_color': None,
|
|
433
|
+
'texts': [{}, {}, {}, {}, {}],
|
|
434
|
+
'marks': [{
|
|
435
|
+
'media': 'ppt/media/ornament.png',
|
|
436
|
+
'box': {'x': 40, 'y': 120, 'w': 80, 'h': 240},
|
|
437
|
+
}],
|
|
438
|
+
}]
|
|
439
|
+
|
|
440
|
+
attach_leftover_image_marks(archetypes, pages, kept_parts=set())
|
|
441
|
+
|
|
442
|
+
self.assertEqual(len(archetypes), 2)
|
|
443
|
+
self.assertEqual(archetypes[0]['slots'], [{
|
|
444
|
+
'role': 'logo',
|
|
445
|
+
'type': 'pic',
|
|
446
|
+
'sz': 0,
|
|
447
|
+
'txt': '',
|
|
448
|
+
'media': 'ppt/media/ornament.png',
|
|
449
|
+
'box': [40, 120, 80, 240],
|
|
450
|
+
}])
|
|
451
|
+
self.assertEqual(archetypes[1]['slots'], [])
|
|
452
|
+
|
|
453
|
+
def test_contact_sheets_cover_all_candidates_without_truncation(self):
|
|
454
|
+
try:
|
|
455
|
+
from PIL import Image
|
|
456
|
+
except ImportError:
|
|
457
|
+
self.skipTest('Pillow unavailable')
|
|
458
|
+
|
|
459
|
+
with tempfile.TemporaryDirectory() as output_dir:
|
|
460
|
+
media_dir = os.path.join(output_dir, 'media-out')
|
|
461
|
+
lout_dir = os.path.join(output_dir, 'l-out')
|
|
462
|
+
os.makedirs(media_dir)
|
|
463
|
+
os.makedirs(lout_dir)
|
|
464
|
+
candidates = []
|
|
465
|
+
for index in range(13):
|
|
466
|
+
filename = 'image-%02d.png' % index
|
|
467
|
+
Image.new('RGBA', (8, 8), (index, 20, 30, 255)).save(
|
|
468
|
+
os.path.join(media_dir, filename))
|
|
469
|
+
candidates.append({
|
|
470
|
+
'file': filename,
|
|
471
|
+
'out': 'media-out/' + filename,
|
|
472
|
+
'n': 1,
|
|
473
|
+
'probe': {'w': 8, 'h': 8},
|
|
474
|
+
})
|
|
475
|
+
|
|
476
|
+
sheets = contact_sheets(output_dir, candidates, lout_dir)
|
|
477
|
+
legacy_exists = os.path.exists(os.path.join(lout_dir, 'contact-sheet.png'))
|
|
478
|
+
|
|
479
|
+
self.assertEqual(len(sheets), 2)
|
|
480
|
+
self.assertEqual(
|
|
481
|
+
[os.path.basename(path) for path in sheets],
|
|
482
|
+
['contact-sheet-1.png', 'contact-sheet-2.png'],
|
|
483
|
+
)
|
|
484
|
+
self.assertTrue(legacy_exists)
|
|
485
|
+
|
|
486
|
+
def test_content_candidates_at_same_geometry_collapse_to_one_slot(self):
|
|
487
|
+
lines = resolve_asset_candidate_lines([
|
|
488
|
+
' - {role: asset-candidate, box: [10, 20, 300, 200], type: pic, '
|
|
489
|
+
'source_media: chart-a.png}',
|
|
490
|
+
' - {role: asset-candidate, box: [10, 20, 300, 200], type: pic, '
|
|
491
|
+
'source_media: chart-b.png}',
|
|
492
|
+
], {
|
|
493
|
+
'chart-a.png': 'content',
|
|
494
|
+
'chart-b.png': 'content',
|
|
495
|
+
}, {})
|
|
496
|
+
|
|
497
|
+
self.assertEqual(lines, [
|
|
498
|
+
' - {role: pic, box: [10, 20, 300, 200], type: pic}',
|
|
499
|
+
])
|
|
500
|
+
|
|
96
501
|
|
|
97
502
|
if __name__ == '__main__':
|
|
98
503
|
unittest.main()
|
|
@@ -163,6 +163,46 @@ class TextRoleContractTest(unittest.TestCase):
|
|
|
163
163
|
)
|
|
164
164
|
self.assertIn('css: "font-size: 48px; color: #C41230"', layouts_md)
|
|
165
165
|
|
|
166
|
+
def test_template_layout_shape_without_ph_key_does_not_crash(self):
|
|
167
|
+
# form=3 模板里,版式层可能有「带 box、带文字、但没有 ph 键」的普通形状
|
|
168
|
+
# (非占位符的文本/装饰)。layouts_from_template 的入口筛选是
|
|
169
|
+
# `s.get('ph') or shape_text(s)`——有文字就放进来,随后按 ph 判类型时若用
|
|
170
|
+
# s['ph'] 直接下标就会 KeyError: 'ph',整份抽取在草案阶段崩掉(EXTRACT_PARTIAL)。
|
|
171
|
+
layout_part = 'ppt/slideLayouts/slideLayout1.xml'
|
|
172
|
+
shape = {
|
|
173
|
+
'part': layout_part,
|
|
174
|
+
'layer': 'layout',
|
|
175
|
+
'id': '7',
|
|
176
|
+
'kind': 'sp',
|
|
177
|
+
'name': '页脚文字',
|
|
178
|
+
# 关键:没有 'ph' 键
|
|
179
|
+
'box': {'x': 100, 'y': 980, 'w': 800, 'h': 60},
|
|
180
|
+
'text': {
|
|
181
|
+
'bodyPr': {},
|
|
182
|
+
'lstStyle': {'lvl1pPr': {'sz_px': 20}},
|
|
183
|
+
'paragraphs': [{'runs': [{'text': '内部资料'}]}],
|
|
184
|
+
},
|
|
185
|
+
}
|
|
186
|
+
data = {
|
|
187
|
+
'canvas': {'px': [1920, 1080]},
|
|
188
|
+
'form_hint': {'form': 3},
|
|
189
|
+
'layouts': [{'part': layout_part}],
|
|
190
|
+
'slides': [{'part': 'ppt/slides/slide1.xml', 'layout': layout_part,
|
|
191
|
+
'background': None}],
|
|
192
|
+
'background_composites': {},
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
with tempfile.TemporaryDirectory() as output_dir:
|
|
196
|
+
os.makedirs(os.path.join(output_dir, 'ref'))
|
|
197
|
+
with open(os.path.join(output_dir, 'ref', 'shapes.json'), 'w',
|
|
198
|
+
encoding='utf-8') as stream:
|
|
199
|
+
json.dump({'shapes': [shape]}, stream)
|
|
200
|
+
# 修复前这里抛 KeyError: 'ph'(draft.py 用 s['ph'] 直接下标),
|
|
201
|
+
# 抽取在草案阶段崩掉、退成 EXTRACT_PARTIAL。修复后应正常返回。
|
|
202
|
+
archetypes, pages, leftover = draft_layouts(data, output_dir)
|
|
203
|
+
|
|
204
|
+
self.assertIsInstance(archetypes, list)
|
|
205
|
+
|
|
166
206
|
|
|
167
207
|
if __name__ == '__main__':
|
|
168
208
|
unittest.main()
|
|
@@ -99,7 +99,7 @@ assets:
|
|
|
99
99
|
- 方案甲·包内(抽取产物默认形态):大图保留原图 + 压缩图(`<name>@full.<ext>` / `<name>.<ext>`,`path` 指压缩图、`full` 指原图),消费侧优先用压缩图;压缩图 >500KB WARN、包内总量 >20MB FAIL。
|
|
100
100
|
- 方案乙·平台云盘(入库后目标形态):条目用 `url`,消费时按云盘图片处理参数取压缩版;包内不落二进制,体积约束不适用。入库时由后端把 `path`/`full` 重写为 `url`(重写版仍须过 V2-1/V2-12)。
|
|
101
101
|
- `url` 必须 http(s) 持久地址,禁 24h TTL 签名 URL。
|
|
102
|
-
-
|
|
102
|
+
- 图片按用途三分:整幅替换底图的满屏主视觉(含封面艺术图)属**背景族**,照收;服务于具体内容的图表/截图/产品说明图是**内容图,不进包**;既非 logo、又不服务内容的纹理/装饰插画/色块/几何点缀是**装饰图,标 `texture`**。`logo` 特指这份 deck 自己的品牌标志——logo 墙里的第三方 logo 算内容图。被遮挡/无用资产不进包;抽不出不编造(记 `gaps`,logo 候选图存 `ref/logo-candidates/`)。
|
|
103
103
|
|
|
104
104
|
## 2.5 `## Usage` 章节(正文必产,紧随 Overview)
|
|
105
105
|
|
|
@@ -107,10 +107,10 @@ design.md 是消费模型的操作文档,不是抽取记录。`## Usage` 承
|
|
|
107
107
|
|
|
108
108
|
1. **消费步骤**:① 画布取 `layouts.md` 的 `canvas`;② 从页型清单选 archetype,按其 flow / slots / decor / background 原样落版;③ `design.md` frontmatter 的 colors / typography / spacing / rounded / components 作为全局 token,局部 CSS 优先;④ 包内资产复制到项目相对目录后引用,字体使用完整 fallback 栈且不在运行时安装;⑤ 双主题包写明默认主题与 token 前缀切换法。
|
|
109
109
|
2. **资产用法表**:每个资产一行——id、文件路径、用在哪类页、怎么摆(logo 给坐标,背景给首选序——如「封面首选 cover-art,无主视觉需求用 cover-dark」)。
|
|
110
|
-
3. **强调色族纪律**:强调色族以 `colors` 段和 layout slot CSS
|
|
110
|
+
3. **强调色族纪律**:强调色族以 `colors` 段和 layout slot CSS 为主。必要时可使用其他颜色,但新增颜色须与模板整体的色相、明度和饱和度关系协调,且不能形成与模板主色竞争的第二强调色。中性色、低彩度辅助色或局部语义色可表达正负、风险、警告、状态、图表序列,但须保持辅助层级;新色不得通过高饱和、高对比、大面积、跨页重复,或用于标题、关键数字、图表主序列、卡片底色、渐变来获得主视觉权重。
|
|
111
111
|
4. **交付检查**:逐页确认色板、字体、版式、背景、资产和 Hard Rules 均来自本包,并检查资源加载、内容溢出与画幅裁切。
|
|
112
112
|
|
|
113
|
-
**Hard Rules 必须包含对应的正向硬规则**(有资产的包):每页放 logo(位置+文件);封面底图必用 cover 资产;版式从 layouts.md
|
|
113
|
+
**Hard Rules 必须包含对应的正向硬规则**(有资产的包):每页放 logo(位置+文件);封面底图必用 cover 资产;版式从 layouts.md 取;以 colors / layout slot CSS 为强调色基准,新增颜色与整体色板协调并保持辅助层级。禁止句只用于无法正向表达的红线,且同句给替代。
|
|
114
114
|
|
|
115
115
|
## 3. `layouts` 段(默认 sidecar)
|
|
116
116
|
|
|
@@ -133,6 +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。区带可带自己的 `margin: [左, 右]`,覆盖 `flow` 整块的 `margin`(居中卡片组和贴左标题横向范围本就不同);不带则继承整块 `margin`。纵向位置与留白由消费模型结合实际内容决定,不把样张的 `y` 坐标当作流式硬约束。
|
|
136
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 之上是版式本意,不算重叠。
|
|
137
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` 等旧字段。
|
|
138
139
|
- **文本角色判断**:脚本把实例页及其引用版式中的现有文本槽、几何和 CSS 完整写入草案;`text_roles` 只供模型把这些槽判断为 `title | subtitle | header | footer | body`,不控制槽位去留。判断不清时用 `body`,不归纳模板中不存在的标题、页眉或页脚。
|
|
@@ -395,6 +395,8 @@ async createArticle(@Req() req: Request, @Body() dto: CreateArticleDto) {
|
|
|
395
395
|
| 语音识别、音频转文字、STT | AI语音转文字 |
|
|
396
396
|
| 插件实例、PluginInstance、Capability | 通用插件调用 |
|
|
397
397
|
|
|
398
|
+
> ⚠️ **语义检索 / 相似推荐场景例外**:若需求是对**应用自有数据**做语义检索、向量检索、相似推荐、相关推荐、按内容找相似,**不要**用上表的「AI搜索总结 / AI文本转JSON / AI智能生文」等通用插件替代,必须召回 `/semantic-search`。上表的 AI 搜索/文本插件用于公网搜索、文本生成、结构化抽取,**不具备对自有数据库的向量检索能力**。
|
|
399
|
+
|
|
398
400
|
### 关键区分:多维表格 vs 数据库表
|
|
399
401
|
|
|
400
402
|
| 场景 | 判断 | 操作 |
|
|
@@ -497,6 +499,14 @@ import { axiosForBackend } from '@lark-apaas/client-toolkit/utils/getAxiosForBac
|
|
|
497
499
|
|
|
498
500
|
禁止未调用 Skill 直接编写表单/图表/表格代码。
|
|
499
501
|
|
|
502
|
+
### 检索 Skill 召回规则(强制执行)
|
|
503
|
+
|
|
504
|
+
| 场景 | Skill | 说明 |
|
|
505
|
+
|------|-------|------|
|
|
506
|
+
| 语义检索、向量检索、相似推荐、相关推荐、相似内容、按内容找相似、猜你喜欢 | `/semantic-search` | 对应用自有数据库的数据做向量检索 / 相似召回 |
|
|
507
|
+
|
|
508
|
+
禁止未调用 Skill 直接用「AI搜索总结 / AI文本转JSON / AI智能生文」等通用插件实现对自有数据的语义检索。
|
|
509
|
+
|
|
500
510
|
### 组件使用规范
|
|
501
511
|
|
|
502
512
|
- 优先使用 `client/src/components` 下已有组件(Card/Button/Badge 等)
|
|
@@ -215,7 +215,7 @@ const structured = await capabilityClient
|
|
|
215
215
|
- **Plugin(插件)**:底层承载单元,包含插件元信息与表单定义(form.schema)。模型侧只感知插件及其表单字段,不感知插件内部实现细节。
|
|
216
216
|
- **PluginInstance(插件实例配置)**:基于某个 Plugin 的表单做"业务封装",以 **单文件 JSON** 的形式存储(每个插件实例一个文件,语义化 id)。
|
|
217
217
|
- 通过 `paramsSchema` 暴露业务入参
|
|
218
|
-
- 通过 `formValue` 将业务入参映射到插件表单字段(可常量或引用 `{{input.xxx}}`)
|
|
218
|
+
- 通过 `formValue` 将业务入参映射到插件表单字段(可常量或引用 `{% raw %}{{input.xxx}}{% endraw %}`)
|
|
219
219
|
- **PluginInstanceAIJson(pluginInstance.ai.json)**:工程转化层产物,是 pluginInstance 的**运行时投影 / 调用合同(Runtime Spec)**。
|
|
220
220
|
- 包含插件定位信息、actions 入口列表、input/output schema、outputMode、readme 等
|
|
221
221
|
- Code Agent 在生成**调用代码**前,必须读取它作为权威依据(Server 侧用 `CapabilityService`,Client 侧用 `capabilityClient`)
|
|
@@ -258,6 +258,7 @@ Plugin 的具体内容以JSON格式给出,例如:
|
|
|
258
258
|
|
|
259
259
|
|
|
260
260
|
PluginInstance 的配置以 JSON 形式输出,例如:
|
|
261
|
+
{% raw %}
|
|
261
262
|
```json
|
|
262
263
|
{
|
|
263
264
|
"id": "create_feishu_group", // 全局唯一语义化 ID
|
|
@@ -279,6 +280,7 @@ PluginInstance 的配置以 JSON 形式输出,例如:
|
|
|
279
280
|
}
|
|
280
281
|
}
|
|
281
282
|
```
|
|
283
|
+
{% endraw %}
|
|
282
284
|
|
|
283
285
|
**注意**paramsSchema 支持以下 4 种参数类型,需要按下面规定的格式进行填充:
|
|
284
286
|
|
|
@@ -560,7 +562,7 @@ PluginInstanceAIJson 的配置以 JSON 形式输出,例如:
|
|
|
560
562
|
| 未按 `outputSchema` 解析返回值,猜测返回结构 | 严格按 `get_plugin_ai_json` 返回的 `outputSchema` 读取字段,流式和非流式均适用 |
|
|
561
563
|
| 未输出 Schema 摘录卡就直接写调用代码 | 先完成“编码前闸门”中的摘录卡,再开始编码 |
|
|
562
564
|
| 改完未做真实调用冒烟就宣告完成 | 至少完成一次 unary/stream 真实调用验证,并附最小日志字段 |
|
|
563
|
-
| formValue 中用 `["{{input.xxx}}"]` 包装已经是 `type: array` 的 paramsSchema 参数 | 当 paramsSchema 定义为 array 时,formValue 应透传 `"{{input.xxx}}"`,不要再包一层数组 |
|
|
565
|
+
| formValue 中用 `{% raw %}["{{input.xxx}}"]{% endraw %}` 包装已经是 `type: array` 的 paramsSchema 参数 | 当 paramsSchema 定义为 array 时,formValue 应透传 `{% raw %}"{{input.xxx}}"{% endraw %}`,不要再包一层数组 |
|
|
564
566
|
| 通过 `getDataloom().capability` 或 `(dataloom as any).capability` 调用插件 | `capabilityClient` 是独立导入,不通过 dataloom 访问。dataloom 仅提供 storage 和 service |
|
|
565
567
|
| Client 侧调用插件时,先通过 dataloom 上传文件拿 URL 再传给插件 | Client 侧可直接传 File/Blob 对象给 `capabilityClient`,SDK 自动处理上传。适用于所有文件类型字段(`format` 为 `file`/`picture`/`plugin-file-url`)。Server 侧仍需传 URL |
|
|
566
568
|
| 前端调用插件后不保存结果到数据库,导致页面刷新后数据丢失 | 需要持久化时:优先在 Server 侧调用并直接落库(方案A);若在 Client 侧调用,必须通过已有 CRUD 接口立即保存结果(方案B) |
|
|
@@ -642,7 +644,7 @@ try {
|
|
|
642
644
|
| 场景 | 正确做法 | 示例 |
|
|
643
645
|
|------|---------|------|
|
|
644
646
|
| 需求明确的**固定**接收人/配置 | 在 `plugin_instance CREATE` 的 `formValue` 中直接写死 | `formValue.receiverUserList: ["1854102143505690"]` |
|
|
645
|
-
| **动态**接收人/配置(按角色/条件变化) | 从配置/平台 API/DB 获取,传入 `input` 参数 | `formValue.receiverUserList: "{{input.receiverIds}}"` |
|
|
647
|
+
| **动态**接收人/配置(按角色/条件变化) | 从配置/平台 API/DB 获取,传入 `input` 参数 | `{% raw %}formValue.receiverUserList: "{{input.receiverIds}}"{% endraw %}` |
|
|
646
648
|
|
|
647
649
|
> **关键区分**:`formValue` 中配置固定值 ≠ 代码中硬编码。`formValue` 是插件实例的声明式配置,修改不需要改代码;而代码中硬编码的值散落在业务逻辑中,难以维护。
|
|
648
650
|
|
|
@@ -8,6 +8,8 @@ steering-topic: plugin_guide
|
|
|
8
8
|
match-template-name: nestjs-react-fullstack
|
|
9
9
|
---
|
|
10
10
|
|
|
11
|
+
{% raw %}
|
|
12
|
+
|
|
11
13
|
# Plugin 集成指南(本地开发)
|
|
12
14
|
|
|
13
15
|
AI 插件集成规范,使用 lark-cli 命令管理插件包与实例,通过 capabilityClient / CapabilityService 生成调用代码。
|
|
@@ -578,3 +580,5 @@ npx @lark-apaas/miaoda-cli plugin list --id <instance_id>
|
|
|
578
580
|
5. **禁止用 `npm install` 安装插件包** — 插件包和 npm 包是两套独立机制。
|
|
579
581
|
6. **禁止 Mock** — 必须走真实插件实例调用链路。
|
|
580
582
|
7. **formValue 禁止 Handlebars 控制语法** — 仅允许 `{{input.xxx}}`。
|
|
583
|
+
|
|
584
|
+
{% endraw %}
|
|
@@ -180,7 +180,7 @@ const structured = await (jsonExtractor as any).call('textToJson', { text: rawRe
|
|
|
180
180
|
- **Plugin(插件)**:底层承载单元,包含插件元信息与表单定义(form.schema)。模型侧只感知插件及其表单字段,不感知插件内部实现细节。
|
|
181
181
|
- **PluginInstance(插件实例配置)**:基于某个 Plugin 的表单做"业务封装",以 **单文件 JSON** 的形式存储(每个插件实例一个文件,语义化 id)。
|
|
182
182
|
- 通过 `paramsSchema` 暴露业务入参
|
|
183
|
-
- 通过 `formValue` 将业务入参映射到插件表单字段(可常量或引用 `{{input.xxx}}`)
|
|
183
|
+
- 通过 `formValue` 将业务入参映射到插件表单字段(可常量或引用 `{% raw %}{{input.xxx}}{% endraw %}`)
|
|
184
184
|
- **PluginInstanceAIJson(pluginInstance.ai.json)**:工程转化层产物,是 pluginInstance 的**运行时投影 / 调用合同(Runtime Spec)**。
|
|
185
185
|
- 包含插件定位信息、actions 入口列表、input/output schema、outputMode、readme 等
|
|
186
186
|
- Code Agent 在生成**调用代码**前,必须读取它作为权威依据(使用 `capabilityClient` 调用)
|
|
@@ -224,6 +224,7 @@ Plugin 的具体内容以JSON格式给出,例如:
|
|
|
224
224
|
|
|
225
225
|
PluginInstance 的配置以 JSON 形式输出,例如:
|
|
226
226
|
|
|
227
|
+
{% raw %}
|
|
227
228
|
```json
|
|
228
229
|
{
|
|
229
230
|
"id": "create_feishu_group", // 全局唯一语义化 ID
|
|
@@ -245,6 +246,7 @@ PluginInstance 的配置以 JSON 形式输出,例如:
|
|
|
245
246
|
}
|
|
246
247
|
}
|
|
247
248
|
```
|
|
249
|
+
{% endraw %}
|
|
248
250
|
|
|
249
251
|
**注意**paramsSchema 支持以下 4 种参数类型,需要按下面规定的格式进行填充:
|
|
250
252
|
|
|
@@ -5,6 +5,8 @@ steering: true
|
|
|
5
5
|
steering-topic: react_three_fiber
|
|
6
6
|
---
|
|
7
7
|
|
|
8
|
+
{% raw %}
|
|
9
|
+
|
|
8
10
|
# React Three Fiber (R3F) 编码指南
|
|
9
11
|
|
|
10
12
|
实现 3D 场景 / 3D 游戏 / 3D 数据可视化时, MUST 用 **react-three-fiber + drei** 声明式栈, 严禁用 React + CSS / SVG / `transform: rotateX` 伪 3D.
|
|
@@ -220,3 +222,5 @@ npm install react-error-boundary # 必装! Canvas 外包 ErrorBoundary
|
|
|
220
222
|
|
|
221
223
|
- `client-coding-guide` - vite-react 通用编码规范
|
|
222
224
|
- `component-conventions` - React 组件命名 / 文件结构
|
|
225
|
+
|
|
226
|
+
{% endraw %}
|