@lark-apaas/coding-steering 0.1.32-dev.5abff3b → 0.1.32-dev.7b2c2ad

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.
@@ -3,18 +3,33 @@
3
3
  import io
4
4
  import os
5
5
  import sys
6
+ import tempfile
6
7
  import unittest
8
+ from unittest import mock
9
+ from xml.etree import ElementTree as ET
7
10
 
8
11
  from PIL import Image
9
12
 
10
13
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
11
14
 
12
- from extract import _draw_picture # noqa: E402
15
+ import extract # noqa: E402
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
13
27
 
14
28
 
15
29
  class _Archive:
16
30
  def __init__(self, media, raw):
17
31
  self.names = {media}
32
+ self.media = [media]
18
33
  self.zip = self
19
34
  self.raw = raw
20
35
 
@@ -23,6 +38,35 @@ class _Archive:
23
38
  raise KeyError(media)
24
39
  return self.raw
25
40
 
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
+
26
70
 
27
71
  def _png(pixels):
28
72
  image = Image.new('RGBA', (len(pixels), 1))
@@ -52,6 +96,269 @@ class BackgroundCompositeTest(unittest.TestCase):
52
96
  self.assertEqual(left[3], 255)
53
97
  self.assertEqual(right[3], 255)
54
98
 
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
+
55
362
 
56
363
  if __name__ == '__main__':
57
364
  unittest.main()
@@ -57,6 +57,19 @@ class DesignConsumerContractTest(unittest.TestCase):
57
57
  self.assertIn('背景中已经可见的固定标题不再创建文本', body)
58
58
  self.assertIn('没有 `subtitle` 槽就不新增副标题', body)
59
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)
60
73
 
61
74
 
62
75
  if __name__ == '__main__':