@lark-apaas/coding-steering 0.1.38 → 0.1.39-alpha.20260824122813
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 +6 -6
- package/steering/design-html/skills/pptx-style-extract/SKILL.md +19 -28
- package/steering/design-html/skills/pptx-style-extract/scripts/census.py +2 -8
- package/steering/design-html/skills/pptx-style-extract/scripts/draft.py +190 -1181
- package/steering/design-html/skills/pptx-style-extract/scripts/extract.py +10 -229
- package/steering/design-html/skills/pptx-style-extract/scripts/ooxml.py +1 -18
- package/steering/design-html/skills/pptx-style-extract/scripts/package.py +40 -643
- package/steering/design-html/skills/pptx-style-extract/scripts/parts.py +3 -19
- package/steering/design-html/skills/pptx-style-extract/scripts/render_pages.py +2 -4
- package/steering/design-html/skills/pptx-style-extract/scripts/test_background_composite.py +1 -308
- package/steering/design-html/skills/pptx-style-extract/scripts/test_design_consumer_contract.py +0 -14
- package/steering/design-html/skills/pptx-style-extract/scripts/test_flow_layout_contract.py +7 -157
- package/steering/design-html/skills/pptx-style-extract/scripts/test_layout_css.py +2 -1598
- package/steering/design-html/skills/pptx-style-extract/scripts/test_text_role_contract.py +4 -151
- package/steering/design-html/skills/pptx-style-extract/v2-format-spec.md +1 -1
- package/steering/design-html/skills/preflight/scripts/probe.sh +0 -0
- package/steering/nestjs-react-fullstack/skills/coding-guide/SKILL.md +0 -11
- package/steering/nestjs-react-fullstack/skills/contacts-service/SKILL.md +4 -67
- package/steering/nestjs-react-fullstack/skills/nestjs-cache/SKILL.md +1 -0
- package/steering/nestjs-react-fullstack/skills/openapi-guide/SKILL.md +0 -75
- package/steering/nestjs-react-fullstack/skills_local/coding-guide/SKILL.md +7 -5
- package/steering/design-html/skills/pptx-style-extract/scripts/test_asset_judgment_package.py +0 -556
- package/steering/design-html/skills/pptx-style-extract/scripts/test_logo_scope.py +0 -479
- package/steering/design-html/skills/pptx-style-extract/scripts/verify_layout_assets.py +0 -400
- package/steering/design-html/skills/pptx-style-extract/scripts/verify_logo_scope.py +0 -12
|
@@ -299,22 +299,6 @@ NV_TAGS = {'sp': 'p:nvSpPr', 'pic': 'p:nvPicPr', 'grpSp': 'p:nvGrpSpPr',
|
|
|
299
299
|
'cxnSp': 'p:nvCxnSpPr', 'graphicFrame': 'p:nvGraphicFramePr'}
|
|
300
300
|
|
|
301
301
|
|
|
302
|
-
def blip_opacity(blip):
|
|
303
|
-
"""Combine the alpha modulation transforms attached to one local picture."""
|
|
304
|
-
opacity = 1.0
|
|
305
|
-
found = False
|
|
306
|
-
for effect in blip:
|
|
307
|
-
if local(effect.tag) not in ('alphaMod', 'alphaModFix'):
|
|
308
|
-
continue
|
|
309
|
-
try:
|
|
310
|
-
amount = int(effect.get('amt'))
|
|
311
|
-
except (TypeError, ValueError):
|
|
312
|
-
continue
|
|
313
|
-
opacity *= max(0.0, min(amount / 100000.0, 1.0))
|
|
314
|
-
found = True
|
|
315
|
-
return round(opacity, 6) if found else None
|
|
316
|
-
|
|
317
|
-
|
|
318
302
|
def walk_tree(el, ctx, out, path=(), xf=(1.0, 1.0, 0.0, 0.0), depth=0):
|
|
319
303
|
sx, sy, dx, dy = xf
|
|
320
304
|
U, W, H = ctx.units, ctx.units.w, ctx.units.h
|
|
@@ -409,9 +393,9 @@ def walk_tree(el, ctx, out, path=(), xf=(1.0, 1.0, 0.0, 0.0), depth=0):
|
|
|
409
393
|
blip = sp.find('p:blipFill/a:blip', NS)
|
|
410
394
|
if blip is not None:
|
|
411
395
|
rec['media'] = ctx.media_of(blip.get(R_EMBED)) or ctx.media_of(blip.get(R_LINK))
|
|
412
|
-
|
|
413
|
-
if
|
|
414
|
-
rec['opacity'] =
|
|
396
|
+
alpha = blip.find('a:alphaModFix', NS)
|
|
397
|
+
if alpha is not None and alpha.get('amt') is not None:
|
|
398
|
+
rec['opacity'] = round(int(alpha.get('amt')) / 100000.0, 6)
|
|
415
399
|
svg = blip.find('a:extLst//asvg:svgBlip', NS)
|
|
416
400
|
if svg is not None:
|
|
417
401
|
rec['media_svg'] = ctx.media_of(svg.get(R_EMBED))
|
|
@@ -88,15 +88,13 @@ def css_gradient(grad):
|
|
|
88
88
|
def background_css(bg):
|
|
89
89
|
"""页底 <p:bg> 描述符 → CSS background 值。解析不出返回 None,交上层回退到下一层底色。
|
|
90
90
|
|
|
91
|
-
bgPr solid / bgRef 都带 `color
|
|
92
|
-
|
|
91
|
+
bgPr solid / bgRef 都带 `color`;gradient 与形状渐变共用 css_gradient;
|
|
92
|
+
背景图不重建(媒体未必导出),按无底色处理。
|
|
93
93
|
"""
|
|
94
94
|
if not bg:
|
|
95
95
|
return None
|
|
96
96
|
kind = bg.get('type')
|
|
97
97
|
if kind == 'gradient':
|
|
98
|
-
if bg.get('path'):
|
|
99
|
-
return None
|
|
100
98
|
return css_gradient(bg)
|
|
101
99
|
if kind in ('image', 'none', 'pattern'):
|
|
102
100
|
return None
|
|
@@ -3,33 +3,18 @@
|
|
|
3
3
|
import io
|
|
4
4
|
import os
|
|
5
5
|
import sys
|
|
6
|
-
import tempfile
|
|
7
6
|
import unittest
|
|
8
|
-
from unittest import mock
|
|
9
|
-
from xml.etree import ElementTree as ET
|
|
10
7
|
|
|
11
8
|
from PIL import Image
|
|
12
9
|
|
|
13
10
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
14
11
|
|
|
15
|
-
import
|
|
16
|
-
from extract import ( # noqa: E402
|
|
17
|
-
_draw_picture,
|
|
18
|
-
_is_full_canvas_fill_overlay,
|
|
19
|
-
compose_backgrounds,
|
|
20
|
-
export_media,
|
|
21
|
-
export_visible_picture_instances,
|
|
22
|
-
)
|
|
23
|
-
from census import image_census # noqa: E402
|
|
24
|
-
from ooxml import Units # noqa: E402
|
|
25
|
-
from parts import walk_tree # noqa: E402
|
|
26
|
-
from render_pages import background_css # noqa: E402
|
|
12
|
+
from extract import _draw_picture # noqa: E402
|
|
27
13
|
|
|
28
14
|
|
|
29
15
|
class _Archive:
|
|
30
16
|
def __init__(self, media, raw):
|
|
31
17
|
self.names = {media}
|
|
32
|
-
self.media = [media]
|
|
33
18
|
self.zip = self
|
|
34
19
|
self.raw = raw
|
|
35
20
|
|
|
@@ -38,35 +23,6 @@ class _Archive:
|
|
|
38
23
|
raise KeyError(media)
|
|
39
24
|
return self.raw
|
|
40
25
|
|
|
41
|
-
def size_of(self, media):
|
|
42
|
-
if media not in self.names:
|
|
43
|
-
raise KeyError(media)
|
|
44
|
-
return len(self.raw)
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
class _CompositeArchive(_Archive):
|
|
48
|
-
def __init__(self, media, raw, slide):
|
|
49
|
-
super().__init__(media, raw)
|
|
50
|
-
self.layouts = []
|
|
51
|
-
self.slides = [slide]
|
|
52
|
-
|
|
53
|
-
@staticmethod
|
|
54
|
-
def xml(_part):
|
|
55
|
-
return {}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
class _ShapeContext:
|
|
59
|
-
def __init__(self, media):
|
|
60
|
-
self.part = 'ppt/slides/slide1.xml'
|
|
61
|
-
self.layer = 'slide'
|
|
62
|
-
self.units = Units(1920, 1080)
|
|
63
|
-
self.clrmap = {}
|
|
64
|
-
self.clrscheme = {}
|
|
65
|
-
self.media = media
|
|
66
|
-
|
|
67
|
-
def media_of(self, _relationship_id):
|
|
68
|
-
return self.media
|
|
69
|
-
|
|
70
26
|
|
|
71
27
|
def _png(pixels):
|
|
72
28
|
image = Image.new('RGBA', (len(pixels), 1))
|
|
@@ -96,269 +52,6 @@ class BackgroundCompositeTest(unittest.TestCase):
|
|
|
96
52
|
self.assertEqual(left[3], 255)
|
|
97
53
|
self.assertEqual(right[3], 255)
|
|
98
54
|
|
|
99
|
-
def test_exports_a_cropped_rotated_visible_instance_from_a_near_blank_source(self):
|
|
100
|
-
media = 'ppt/media/source.png'
|
|
101
|
-
source = Image.new('RGBA', (1000, 1000))
|
|
102
|
-
for x in range(460, 540):
|
|
103
|
-
for y in range(460, 540):
|
|
104
|
-
source.putpixel((x, y), (15, 80, 160, 255))
|
|
105
|
-
raw = io.BytesIO()
|
|
106
|
-
source.save(raw, 'PNG')
|
|
107
|
-
package = _Archive(media, raw.getvalue())
|
|
108
|
-
shape = {
|
|
109
|
-
'kind': 'pic',
|
|
110
|
-
'media': media,
|
|
111
|
-
'part': 'ppt/slides/slide2.xml',
|
|
112
|
-
'box_unrotated': {'x': 100, 'y': 200, 'w': 20, 'h': 100},
|
|
113
|
-
'box': {'x': 50, 'y': 240, 'w': 120, 'h': 20},
|
|
114
|
-
'crop': {'l': 40, 't': 40, 'r': 40, 'b': 40},
|
|
115
|
-
'rot': 90,
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
with tempfile.TemporaryDirectory() as outdir:
|
|
119
|
-
rows = export_visible_picture_instances(package, [shape], outdir, True)
|
|
120
|
-
self.assertEqual(len(rows), 1)
|
|
121
|
-
self.assertTrue(rows[0]['generated'])
|
|
122
|
-
self.assertEqual(rows[0]['rendered_from'], media)
|
|
123
|
-
self.assertTrue(shape['media'].startswith('generated/instance/'))
|
|
124
|
-
|
|
125
|
-
rendered = Image.open(os.path.join(outdir, rows[0]['out'])).convert('RGBA')
|
|
126
|
-
self.assertGreater(rendered.width, rendered.height)
|
|
127
|
-
self.assertGreater(sum(pixel[3] for pixel in rendered.getdata()) / rendered.width
|
|
128
|
-
/ rendered.height, 13)
|
|
129
|
-
|
|
130
|
-
def test_instance_webp_writer_removes_partial_output_after_a_save_error(self):
|
|
131
|
-
canvas = Image.new('RGBA', (1, 1), (15, 80, 160, 255))
|
|
132
|
-
|
|
133
|
-
with tempfile.TemporaryDirectory() as outdir:
|
|
134
|
-
path = os.path.join(outdir, 'instance.webp')
|
|
135
|
-
with mock.patch.object(Image.Image, 'save', side_effect=OSError('webp unavailable')):
|
|
136
|
-
reason = extract._save_picture_instance_webp(canvas, path)
|
|
137
|
-
|
|
138
|
-
self.assertEqual('pillow-error: OSError: webp unavailable', reason)
|
|
139
|
-
self.assertFalse(os.path.exists(path))
|
|
140
|
-
self.assertFalse(os.path.exists(path + '.tmp'))
|
|
141
|
-
|
|
142
|
-
def test_instance_webp_failure_keeps_the_source_media_and_records_the_reason(self):
|
|
143
|
-
media = 'ppt/media/source.png'
|
|
144
|
-
package = _Archive(media, _png([(15, 80, 160, 255)]))
|
|
145
|
-
shape = {
|
|
146
|
-
'kind': 'pic',
|
|
147
|
-
'media': media,
|
|
148
|
-
'part': 'ppt/slides/slide2.xml',
|
|
149
|
-
'layer': 'slide',
|
|
150
|
-
'box_unrotated': {'x': 0, 'y': 0, 'w': 100, 'h': 100},
|
|
151
|
-
'box': {'x': 0, 'y': 0, 'w': 100, 'h': 100},
|
|
152
|
-
'rot': 90,
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
with tempfile.TemporaryDirectory() as outdir:
|
|
156
|
-
blocked = {}
|
|
157
|
-
with mock.patch.object(
|
|
158
|
-
extract, '_save_picture_instance_webp',
|
|
159
|
-
return_value='pillow-error: OSError: webp unavailable'):
|
|
160
|
-
rows = export_visible_picture_instances(
|
|
161
|
-
package, [shape], outdir, True, blocked)
|
|
162
|
-
images, _ = image_census([shape], [], Units(1920, 1080))
|
|
163
|
-
media_rows = export_media(package, images, outdir, True)
|
|
164
|
-
|
|
165
|
-
self.assertEqual([], rows)
|
|
166
|
-
self.assertEqual(media, shape['media'])
|
|
167
|
-
self.assertTrue(images[0]['visible_instance_blocked'])
|
|
168
|
-
self.assertTrue(media_rows[0]['candidate'])
|
|
169
|
-
self.assertIn('visible_instance_fallback', media_rows[0]['reasons'])
|
|
170
|
-
self.assertEqual({
|
|
171
|
-
media: {
|
|
172
|
-
'reason': 'pillow-error: OSError: webp unavailable',
|
|
173
|
-
'attempted_specs': 1,
|
|
174
|
-
'part_refs': ['ppt/slides/slide2.xml'],
|
|
175
|
-
},
|
|
176
|
-
}, blocked)
|
|
177
|
-
|
|
178
|
-
def test_alpha_modulations_combine_for_a_visible_instance(self):
|
|
179
|
-
media = 'ppt/media/source.png'
|
|
180
|
-
package = _Archive(media, _png([(20, 40, 60, 255)]))
|
|
181
|
-
picture_tree = ET.fromstring('''
|
|
182
|
-
<p:spTree xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
|
|
183
|
-
xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"
|
|
184
|
-
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
|
|
185
|
-
<p:pic>
|
|
186
|
-
<p:nvPicPr><p:cNvPr id="1" name="half-alpha"/><p:cNvPicPr/><p:nvPr/></p:nvPicPr>
|
|
187
|
-
<p:blipFill>
|
|
188
|
-
<a:blip r:embed="rId1">
|
|
189
|
-
<a:alphaMod amt="50000"/>
|
|
190
|
-
<a:alphaModFix amt="50000"/>
|
|
191
|
-
</a:blip>
|
|
192
|
-
<a:stretch><a:fillRect/></a:stretch>
|
|
193
|
-
</p:blipFill>
|
|
194
|
-
<p:spPr>
|
|
195
|
-
<a:xfrm><a:off x="0" y="0"/><a:ext cx="100" cy="100"/></a:xfrm>
|
|
196
|
-
<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>
|
|
197
|
-
</p:spPr>
|
|
198
|
-
</p:pic>
|
|
199
|
-
</p:spTree>
|
|
200
|
-
''')
|
|
201
|
-
shapes = []
|
|
202
|
-
walk_tree(picture_tree, _ShapeContext(media), shapes)
|
|
203
|
-
|
|
204
|
-
self.assertEqual(shapes[0]['opacity'], 0.25)
|
|
205
|
-
with tempfile.TemporaryDirectory() as outdir:
|
|
206
|
-
rows = export_visible_picture_instances(package, shapes, outdir, True)
|
|
207
|
-
self.assertEqual(len(rows), 1)
|
|
208
|
-
rendered = Image.open(os.path.join(outdir, rows[0]['out'])).convert('RGBA')
|
|
209
|
-
self.assertAlmostEqual(rendered.getpixel((50, 50))[3], 64, delta=2)
|
|
210
|
-
|
|
211
|
-
def test_composites_full_canvas_gradient_and_solid_fill_overlays(self):
|
|
212
|
-
media = 'ppt/media/source.png'
|
|
213
|
-
slide = 'ppt/slides/slide1.xml'
|
|
214
|
-
package = _CompositeArchive(media, _png([(0, 0, 255, 255)]), slide)
|
|
215
|
-
picture = {
|
|
216
|
-
'part': slide,
|
|
217
|
-
'kind': 'pic',
|
|
218
|
-
'media': media,
|
|
219
|
-
'box_unrotated': {'x': 0, 'y': 0, 'w': 2, 'h': 1},
|
|
220
|
-
'w_pct': 100,
|
|
221
|
-
'h_pct': 100,
|
|
222
|
-
}
|
|
223
|
-
shape_base = {
|
|
224
|
-
'geom': {'prst': 'rect'},
|
|
225
|
-
'line': {'none': True},
|
|
226
|
-
}
|
|
227
|
-
gradient_overlay = {
|
|
228
|
-
**shape_base,
|
|
229
|
-
'part': slide,
|
|
230
|
-
'kind': 'sp',
|
|
231
|
-
'box_unrotated': {'x': 0, 'y': 0, 'w': 2, 'h': 1},
|
|
232
|
-
'w_pct': 100,
|
|
233
|
-
'h_pct': 100,
|
|
234
|
-
'fill': {
|
|
235
|
-
'type': 'gradient',
|
|
236
|
-
'stops': [
|
|
237
|
-
{'pos': 0, 'color': {'resolved': 'rgba(255,0,0,0.5)'}},
|
|
238
|
-
{'pos': 100, 'color': {'resolved': 'rgba(255,0,0,0.5)'}},
|
|
239
|
-
],
|
|
240
|
-
'angle_deg': 0,
|
|
241
|
-
},
|
|
242
|
-
}
|
|
243
|
-
solid_overlay = {
|
|
244
|
-
**shape_base,
|
|
245
|
-
'part': slide,
|
|
246
|
-
'kind': 'sp',
|
|
247
|
-
'box_unrotated': {'x': 0, 'y': 0, 'w': 2, 'h': 1},
|
|
248
|
-
'w_pct': 100,
|
|
249
|
-
'h_pct': 100,
|
|
250
|
-
'fill': {
|
|
251
|
-
'type': 'solid',
|
|
252
|
-
'color': {'resolved': 'rgba(0,255,0,0.5)'},
|
|
253
|
-
},
|
|
254
|
-
}
|
|
255
|
-
text_overlay = dict(solid_overlay, text={
|
|
256
|
-
'paragraphs': [{'runs': [{'text': 'Do not flatten me'}]}],
|
|
257
|
-
})
|
|
258
|
-
|
|
259
|
-
self.assertTrue(_is_full_canvas_fill_overlay(gradient_overlay))
|
|
260
|
-
self.assertFalse(_is_full_canvas_fill_overlay(text_overlay))
|
|
261
|
-
self.assertFalse(_is_full_canvas_fill_overlay({
|
|
262
|
-
**gradient_overlay,
|
|
263
|
-
'fill': {**gradient_overlay['fill'], 'path': 'circle'},
|
|
264
|
-
}))
|
|
265
|
-
with tempfile.TemporaryDirectory() as outdir:
|
|
266
|
-
part_map, rows, _ = compose_backgrounds(
|
|
267
|
-
package,
|
|
268
|
-
{'layout_of_slide': {}, 'master_of_layout': {}},
|
|
269
|
-
[picture, gradient_overlay, solid_overlay, text_overlay],
|
|
270
|
-
{},
|
|
271
|
-
Units(2, 1),
|
|
272
|
-
outdir,
|
|
273
|
-
True,
|
|
274
|
-
)
|
|
275
|
-
|
|
276
|
-
self.assertEqual(set(part_map), {slide})
|
|
277
|
-
self.assertEqual(len(rows), 1)
|
|
278
|
-
self.assertEqual(
|
|
279
|
-
[layer['kind'] for layer in rows[0]['composited_from']],
|
|
280
|
-
['picture', 'fill_overlay', 'fill_overlay'],
|
|
281
|
-
)
|
|
282
|
-
rendered = Image.open(os.path.join(outdir, rows[0]['out'])).convert('RGB')
|
|
283
|
-
red, green, blue = rendered.getpixel((0, 0))
|
|
284
|
-
self.assertGreater(red, 50)
|
|
285
|
-
self.assertGreater(green, 100)
|
|
286
|
-
self.assertGreater(blue, 50)
|
|
287
|
-
|
|
288
|
-
def test_preserves_fill_and_picture_draw_order(self):
|
|
289
|
-
media = 'ppt/media/source.png'
|
|
290
|
-
slide = 'ppt/slides/slide1.xml'
|
|
291
|
-
package = _CompositeArchive(media, _png([(0, 0, 255, 255)]), slide)
|
|
292
|
-
overlay = {
|
|
293
|
-
'part': slide,
|
|
294
|
-
'kind': 'sp',
|
|
295
|
-
'geom': {'prst': 'rect'},
|
|
296
|
-
'line': {'none': True},
|
|
297
|
-
'box_unrotated': {'x': 0, 'y': 0, 'w': 2, 'h': 1},
|
|
298
|
-
'w_pct': 100,
|
|
299
|
-
'h_pct': 100,
|
|
300
|
-
'fill': {'type': 'solid', 'color': {'resolved': 'rgba(255,0,0,0.5)'}},
|
|
301
|
-
}
|
|
302
|
-
picture = {
|
|
303
|
-
'part': slide,
|
|
304
|
-
'kind': 'pic',
|
|
305
|
-
'media': media,
|
|
306
|
-
'box_unrotated': {'x': 0, 'y': 0, 'w': 2, 'h': 1},
|
|
307
|
-
'w_pct': 100,
|
|
308
|
-
'h_pct': 100,
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
with tempfile.TemporaryDirectory() as outdir:
|
|
312
|
-
_, rows, _ = compose_backgrounds(
|
|
313
|
-
package,
|
|
314
|
-
{'layout_of_slide': {}, 'master_of_layout': {}},
|
|
315
|
-
[overlay, picture],
|
|
316
|
-
{},
|
|
317
|
-
Units(2, 1),
|
|
318
|
-
outdir,
|
|
319
|
-
True,
|
|
320
|
-
)
|
|
321
|
-
|
|
322
|
-
rendered = Image.open(os.path.join(outdir, rows[0]['out'])).convert('RGB')
|
|
323
|
-
self.assertEqual(rendered.getpixel((0, 0)), (0, 0, 255))
|
|
324
|
-
|
|
325
|
-
def test_path_gradient_background_is_not_linearized_for_compositing(self):
|
|
326
|
-
media = 'ppt/media/source.png'
|
|
327
|
-
slide = 'ppt/slides/slide1.xml'
|
|
328
|
-
package = _CompositeArchive(media, _png([(0, 0, 255, 255)]), slide)
|
|
329
|
-
picture = {
|
|
330
|
-
'part': slide,
|
|
331
|
-
'kind': 'pic',
|
|
332
|
-
'media': media,
|
|
333
|
-
'box_unrotated': {'x': 0, 'y': 0, 'w': 2, 'h': 1},
|
|
334
|
-
'w_pct': 100,
|
|
335
|
-
'h_pct': 100,
|
|
336
|
-
'rot': 90,
|
|
337
|
-
}
|
|
338
|
-
background = {
|
|
339
|
-
'type': 'gradient',
|
|
340
|
-
'path': 'circle',
|
|
341
|
-
'stops': [
|
|
342
|
-
{'pos': 0, 'color': {'resolved': '#FF0000'}},
|
|
343
|
-
{'pos': 100, 'color': {'resolved': '#00FF00'}},
|
|
344
|
-
],
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
self.assertIsNone(background_css(background))
|
|
348
|
-
with tempfile.TemporaryDirectory() as outdir:
|
|
349
|
-
_, rows, _ = compose_backgrounds(
|
|
350
|
-
package,
|
|
351
|
-
{'layout_of_slide': {}, 'master_of_layout': {}},
|
|
352
|
-
[picture],
|
|
353
|
-
{slide: background},
|
|
354
|
-
Units(2, 1),
|
|
355
|
-
outdir,
|
|
356
|
-
True,
|
|
357
|
-
)
|
|
358
|
-
|
|
359
|
-
rendered = Image.open(os.path.join(outdir, rows[0]['out'])).convert('RGB')
|
|
360
|
-
self.assertEqual(rendered.getpixel((0, 0)), (0, 0, 255))
|
|
361
|
-
|
|
362
55
|
|
|
363
56
|
if __name__ == '__main__':
|
|
364
57
|
unittest.main()
|
package/steering/design-html/skills/pptx-style-extract/scripts/test_design_consumer_contract.py
CHANGED
|
@@ -56,20 +56,6 @@ class DesignConsumerContractTest(unittest.TestCase):
|
|
|
56
56
|
self.assertIn('沿用该页型已有的标题层级与局部 `css`', body)
|
|
57
57
|
self.assertIn('背景中已经可见的固定标题不再创建文本', body)
|
|
58
58
|
self.assertIn('没有 `subtitle` 槽就不新增副标题', body)
|
|
59
|
-
self.assertIn('区带自带 `margin: [左, 右]` 时用它的', body)
|
|
60
|
-
self.assertIn('必须完整阅读本 `design.md` 和 `layouts.md`', body)
|
|
61
|
-
self.assertIn('确认全部页型后再开始搭页', body)
|
|
62
|
-
self.assertIn('不能只看摘要、前几个页型', body)
|
|
63
|
-
self.assertIn('data-pptx-layout="<页型名>"', body)
|
|
64
|
-
self.assertIn('data-pptx-asset="<asset id>"', body)
|
|
65
|
-
self.assertIn(
|
|
66
|
-
'position:absolute;left:<x>px;top:<y>px;width:<w>px;height:<h>px',
|
|
67
|
-
body,
|
|
68
|
-
)
|
|
69
|
-
self.assertIn('元素必须可见', body)
|
|
70
|
-
self.assertIn('`box` 使用全画布 `[0, 0, 1920, 1080]`', body)
|
|
71
|
-
self.assertIn('该页型绑定的背景与图片资产均已使用', body)
|
|
72
|
-
self.assertIn('不得省略或换图', body)
|
|
73
59
|
|
|
74
60
|
|
|
75
61
|
if __name__ == '__main__':
|
|
@@ -9,8 +9,7 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
|
9
9
|
|
|
10
10
|
from draft import draft_flow, draft_layouts, emit_layouts
|
|
11
11
|
from check_v2 import Pack, rule_v2_14, rule_v2_16
|
|
12
|
-
from package import
|
|
13
|
-
split_top_blocks)
|
|
12
|
+
from package import Fail, build_layouts_md, split_top_blocks
|
|
14
13
|
|
|
15
14
|
|
|
16
15
|
def text_slot(role, box, text):
|
|
@@ -102,7 +101,7 @@ layouts:
|
|
|
102
101
|
self.assertNotIn(' flow:', slots_md)
|
|
103
102
|
self.assertNotIn('layout_modes:', slots_md)
|
|
104
103
|
|
|
105
|
-
def
|
|
104
|
+
def test_layout_mode_is_required_when_both_forms_exist(self):
|
|
106
105
|
draft = """names:
|
|
107
106
|
layout-1: 内容页
|
|
108
107
|
roles:
|
|
@@ -124,56 +123,8 @@ layouts:
|
|
|
124
123
|
confidence: high
|
|
125
124
|
"""
|
|
126
125
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
self.assertIn(' slots:', layouts_md)
|
|
130
|
-
self.assertNotIn(' flow:', layouts_md)
|
|
131
|
-
|
|
132
|
-
def test_flow_instance_override_uses_source_box_without_leaking_it(self):
|
|
133
|
-
manifest = {
|
|
134
|
-
'assets': [],
|
|
135
|
-
'asset_vision_groups': [{
|
|
136
|
-
'id': 'vision-1',
|
|
137
|
-
'source_media': '[shared.png]',
|
|
138
|
-
'visual_kind': 'content-image',
|
|
139
|
-
}],
|
|
140
|
-
'asset_decisions': [{
|
|
141
|
-
'source_media': 'shared.png',
|
|
142
|
-
'box': '[100, 200, 80, 80]',
|
|
143
|
-
'visual_kind': 'decorative',
|
|
144
|
-
}],
|
|
145
|
-
}
|
|
146
|
-
decisions, asset_ids = apply_asset_decisions(manifest)
|
|
147
|
-
draft = """names:
|
|
148
|
-
layout-1: 内容页
|
|
149
|
-
roles:
|
|
150
|
-
layout-1: content
|
|
151
|
-
layout_modes:
|
|
152
|
-
layout-1: flow
|
|
153
|
-
layouts:
|
|
154
|
-
layout-1:
|
|
155
|
-
flow:
|
|
156
|
-
top: 100
|
|
157
|
-
margin: [80, 80]
|
|
158
|
-
gap: 40
|
|
159
|
-
regions:
|
|
160
|
-
- kind: stack
|
|
161
|
-
gap: 20
|
|
162
|
-
items:
|
|
163
|
-
- {role: asset-candidate, type: pic, source_media: shared.png, source_box: [100, 200, 80, 80]}
|
|
164
|
-
slots:
|
|
165
|
-
- {role: asset-candidate, box: [100, 200, 80, 80], type: pic, source_media: shared.png}
|
|
166
|
-
confidence: high
|
|
167
|
-
"""
|
|
168
|
-
|
|
169
|
-
layouts_md = build_layouts_md(
|
|
170
|
-
split_top_blocks(draft), (1920, 1080),
|
|
171
|
-
asset_decisions=decisions, asset_ids=asset_ids,
|
|
172
|
-
)
|
|
173
|
-
|
|
174
|
-
self.assertIn('- {role: texture, type: pic, asset: texture-1}', layouts_md)
|
|
175
|
-
self.assertNotIn('source_media', layouts_md)
|
|
176
|
-
self.assertNotIn('source_box', layouts_md)
|
|
126
|
+
with self.assertRaisesRegex(Fail, 'layout-1 缺 layout_modes 判断'):
|
|
127
|
+
build_layouts_md(split_top_blocks(draft), (1920, 1080))
|
|
177
128
|
|
|
178
129
|
def test_layout_mode_rejects_unknown_value(self):
|
|
179
130
|
draft = """names:
|
|
@@ -311,22 +262,14 @@ layouts:
|
|
|
311
262
|
with open(os.path.join(output_dir, 'layouts.yaml'), encoding='utf-8') as stream:
|
|
312
263
|
layouts_yaml = stream.read()
|
|
313
264
|
|
|
314
|
-
self.
|
|
315
|
-
self.assertIn('
|
|
265
|
+
self.assertIn('layout_modes:', layouts_yaml)
|
|
266
|
+
self.assertIn(' layout-1: TODO布局模式', layouts_yaml)
|
|
316
267
|
self.assertIn(' - kind: grid\n cols: 3', layouts_yaml)
|
|
317
268
|
self.assertEqual(layouts_yaml.count(' - role: group'), 3)
|
|
318
269
|
self.assertEqual(layouts_yaml.count(' items:'), 3)
|
|
319
270
|
self.assertEqual(layouts_yaml.count('role: container'), 3)
|
|
320
271
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
self.assertNotIn(' flow:', default_layouts_md)
|
|
324
|
-
self.assertIn(' slots:', default_layouts_md)
|
|
325
|
-
|
|
326
|
-
decided = layouts_yaml.replace(
|
|
327
|
-
'layouts:',
|
|
328
|
-
'layout_modes:\n layout-1: flow\nlayouts:',
|
|
329
|
-
)
|
|
272
|
+
decided = layouts_yaml.replace('TODO布局模式', 'flow')
|
|
330
273
|
layouts_md = build_layouts_md(split_top_blocks(decided), (1920, 1080))
|
|
331
274
|
|
|
332
275
|
self.assertNotIn('layout_modes:', layouts_md)
|
|
@@ -381,99 +324,6 @@ layouts:
|
|
|
381
324
|
['group', 'group', 'group'],
|
|
382
325
|
)
|
|
383
326
|
|
|
384
|
-
def test_centered_card_grid_carries_its_own_symmetric_margin(self):
|
|
385
|
-
# 短标题贴左(x=120..900),三张卡片居中(左右各 360)。整页 margin 取「最左槽 +
|
|
386
|
-
# 最右槽」的外包络 = [120, 360],直接套给居中卡片组会把它拉偏成左对齐(左缝小、
|
|
387
|
-
# 右缝大)。卡片区带应带自己的对称 margin,还原居中。
|
|
388
|
-
archetype = {
|
|
389
|
-
'name': 'layout-1',
|
|
390
|
-
'zh': '标题加三列卡片',
|
|
391
|
-
'role': 'content',
|
|
392
|
-
'bg': None,
|
|
393
|
-
'slots': [
|
|
394
|
-
text_slot('title', [120, 73, 780, 68], '核心能力'),
|
|
395
|
-
],
|
|
396
|
-
'decor': [],
|
|
397
|
-
'pages': [2],
|
|
398
|
-
'rep': 2,
|
|
399
|
-
'pic_n': 0,
|
|
400
|
-
'confidence': 'high',
|
|
401
|
-
}
|
|
402
|
-
for left in (360, 760, 1160):
|
|
403
|
-
archetype['decor'].append({
|
|
404
|
-
'box': [left, 430, 400, 300],
|
|
405
|
-
'geom': 'rect',
|
|
406
|
-
'css': 'background: rgba(255,255,255,0.7)',
|
|
407
|
-
})
|
|
408
|
-
for top in (490, 570):
|
|
409
|
-
archetype['slots'].append(
|
|
410
|
-
text_slot('body', [left + 40, top, 320, 41], '卡片内容'),
|
|
411
|
-
)
|
|
412
|
-
|
|
413
|
-
flow = draft_flow(archetype, {}, (1920, 1080))
|
|
414
|
-
|
|
415
|
-
self.assertIsNotNone(flow)
|
|
416
|
-
self.assertEqual([region['kind'] for region in flow['regions']], ['stack', 'grid'])
|
|
417
|
-
self.assertEqual(flow['margin'], [120, 360])
|
|
418
|
-
grid = flow['regions'][1]
|
|
419
|
-
self.assertEqual(grid.get('margin'), [360, 360])
|
|
420
|
-
|
|
421
|
-
archetype['flow'] = flow
|
|
422
|
-
with tempfile.TemporaryDirectory() as output_dir:
|
|
423
|
-
emit_layouts([archetype], output_dir)
|
|
424
|
-
with open(os.path.join(output_dir, 'layouts.yaml'), encoding='utf-8') as stream:
|
|
425
|
-
layouts_yaml = stream.read()
|
|
426
|
-
decided = layouts_yaml.replace(
|
|
427
|
-
'layouts:',
|
|
428
|
-
'layout_modes:\n layout-1: flow\nlayouts:',
|
|
429
|
-
)
|
|
430
|
-
layouts_md = build_layouts_md(split_top_blocks(decided), (1920, 1080))
|
|
431
|
-
self.assertRegex(layouts_md, r'- kind: grid\n\s+cols: 3\n\s+gap: \[\d+, \d+\]\n\s+margin: \[360, 360\]')
|
|
432
|
-
|
|
433
|
-
def test_symmetric_card_grid_omits_region_margin(self):
|
|
434
|
-
# 卡片组横向范围已和整页一致(都对称),不该冒出多余的区带 margin——保证对已有
|
|
435
|
-
# 对称模板零回归、layouts.md 不膨胀。
|
|
436
|
-
archetype = {
|
|
437
|
-
'name': 'layout-1',
|
|
438
|
-
'zh': '标题加三列卡片',
|
|
439
|
-
'role': 'content',
|
|
440
|
-
'bg': None,
|
|
441
|
-
'slots': [
|
|
442
|
-
text_slot('title', [120, 73, 1680, 68], '核心能力'),
|
|
443
|
-
],
|
|
444
|
-
'decor': [],
|
|
445
|
-
'pages': [2],
|
|
446
|
-
'rep': 2,
|
|
447
|
-
'pic_n': 0,
|
|
448
|
-
'confidence': 'high',
|
|
449
|
-
}
|
|
450
|
-
for left in (120, 720, 1320):
|
|
451
|
-
archetype['decor'].append({
|
|
452
|
-
'box': [left, 430, 480, 300],
|
|
453
|
-
'geom': 'rect',
|
|
454
|
-
'css': 'background: rgba(255,255,255,0.7)',
|
|
455
|
-
})
|
|
456
|
-
for top in (490, 570):
|
|
457
|
-
archetype['slots'].append(
|
|
458
|
-
text_slot('body', [left + 40, top, 400, 41], '卡片内容'),
|
|
459
|
-
)
|
|
460
|
-
|
|
461
|
-
flow = draft_flow(archetype, {}, (1920, 1080))
|
|
462
|
-
|
|
463
|
-
self.assertIsNotNone(flow)
|
|
464
|
-
self.assertEqual(flow['margin'], [120, 120])
|
|
465
|
-
grid = flow['regions'][1]
|
|
466
|
-
self.assertEqual(grid['kind'], 'grid')
|
|
467
|
-
self.assertNotIn('margin', grid)
|
|
468
|
-
|
|
469
|
-
archetype['flow'] = flow
|
|
470
|
-
with tempfile.TemporaryDirectory() as output_dir:
|
|
471
|
-
emit_layouts([archetype], output_dir)
|
|
472
|
-
with open(os.path.join(output_dir, 'layouts.yaml'), encoding='utf-8') as stream:
|
|
473
|
-
layouts_yaml = stream.read()
|
|
474
|
-
grid_block = layouts_yaml.split('- kind: grid', 1)[1].split('items:', 1)[0]
|
|
475
|
-
self.assertNotIn('margin:', grid_block)
|
|
476
|
-
|
|
477
327
|
def test_fixed_template_anchors_stay_in_a_positioned_free_region(self):
|
|
478
328
|
archetype = {
|
|
479
329
|
'name': 'layout-1',
|