test_image_operations.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587
  1. from io import BytesIO
  2. from django.test import TestCase, override_settings
  3. from mock import Mock, patch
  4. from wagtail.core import hooks
  5. from wagtail.images import image_operations
  6. from wagtail.images.exceptions import InvalidFilterSpecError
  7. from wagtail.images.models import Filter, Image
  8. from wagtail.images.tests.utils import get_test_image_file, get_test_image_file_jpeg
  9. class WillowOperationRecorder:
  10. """
  11. This class pretends to be a Willow image but instead, it records
  12. the operations that have been performed on the image for testing
  13. """
  14. format_name = 'jpeg'
  15. def __init__(self, start_size):
  16. self.ran_operations = []
  17. self.start_size = start_size
  18. def __getattr__(self, attr):
  19. def operation(*args, **kwargs):
  20. self.ran_operations.append((attr, args, kwargs))
  21. return self
  22. return operation
  23. def get_size(self):
  24. size = self.start_size
  25. for operation in self.ran_operations:
  26. if operation[0] == 'resize':
  27. size = operation[1][0]
  28. elif operation[0] == 'crop':
  29. crop = operation[1][0]
  30. size = crop[2] - crop[0], crop[3] - crop[1]
  31. return size
  32. class ImageOperationTestCase(TestCase):
  33. operation_class = None
  34. filter_spec_tests = []
  35. filter_spec_error_tests = []
  36. run_tests = []
  37. @classmethod
  38. def make_filter_spec_test(cls, filter_spec, expected_output):
  39. def test_filter_spec(self):
  40. operation = self.operation_class(*filter_spec.split('-'))
  41. # Check the attributes are set correctly
  42. for attr, value in expected_output.items():
  43. self.assertEqual(getattr(operation, attr), value)
  44. test_filter_spec.__name__ = str('test_filter_%s' % filter_spec)
  45. return test_filter_spec
  46. @classmethod
  47. def make_filter_spec_error_test(cls, filter_spec):
  48. def test_filter_spec_error(self):
  49. self.assertRaises(InvalidFilterSpecError, self.operation_class, *filter_spec.split('-'))
  50. test_filter_spec_error.__name__ = str('test_filter_%s_raises_%s' % (
  51. filter_spec, InvalidFilterSpecError.__name__))
  52. return test_filter_spec_error
  53. @classmethod
  54. def make_run_test(cls, filter_spec, image_kwargs, expected_output):
  55. def test_run(self):
  56. image = Image(**image_kwargs)
  57. # Make operation
  58. operation = self.operation_class(*filter_spec.split('-'))
  59. # Make operation recorder
  60. operation_recorder = WillowOperationRecorder((image.width, image.height))
  61. # Run
  62. operation.run(operation_recorder, image, {})
  63. # Check
  64. self.assertEqual(operation_recorder.ran_operations, expected_output)
  65. test_run.__name__ = str('test_run_%s' % filter_spec)
  66. return test_run
  67. @classmethod
  68. def setup_test_methods(cls):
  69. if cls.operation_class is None:
  70. return
  71. # Filter spec tests
  72. for args in cls.filter_spec_tests:
  73. filter_spec_test = cls.make_filter_spec_test(*args)
  74. setattr(cls, filter_spec_test.__name__, filter_spec_test)
  75. # Filter spec error tests
  76. for filter_spec in cls.filter_spec_error_tests:
  77. filter_spec_error_test = cls.make_filter_spec_error_test(filter_spec)
  78. setattr(cls, filter_spec_error_test.__name__, filter_spec_error_test)
  79. # Running tests
  80. for args in cls.run_tests:
  81. run_test = cls.make_run_test(*args)
  82. setattr(cls, run_test.__name__, run_test)
  83. class TestDoNothingOperation(ImageOperationTestCase):
  84. operation_class = image_operations.DoNothingOperation
  85. filter_spec_tests = [
  86. ('original', dict()),
  87. ('blahblahblah', dict()),
  88. ('123456', dict()),
  89. ]
  90. filter_spec_error_tests = [
  91. 'cannot-take-multiple-parameters',
  92. ]
  93. run_tests = [
  94. ('original', dict(width=1000, height=1000), []),
  95. ]
  96. TestDoNothingOperation.setup_test_methods()
  97. class TestFillOperation(ImageOperationTestCase):
  98. operation_class = image_operations.FillOperation
  99. filter_spec_tests = [
  100. ('fill-800x600', dict(width=800, height=600, crop_closeness=0)),
  101. ('hello-800x600', dict(width=800, height=600, crop_closeness=0)),
  102. ('fill-800x600-c0', dict(width=800, height=600, crop_closeness=0)),
  103. ('fill-800x600-c100', dict(width=800, height=600, crop_closeness=1)),
  104. ('fill-800x600-c50', dict(width=800, height=600, crop_closeness=0.5)),
  105. ('fill-800x600-c1000', dict(width=800, height=600, crop_closeness=1)),
  106. ('fill-800000x100', dict(width=800000, height=100, crop_closeness=0)),
  107. ]
  108. filter_spec_error_tests = [
  109. 'fill',
  110. 'fill-800',
  111. 'fill-abc',
  112. 'fill-800xabc',
  113. 'fill-800x600-',
  114. 'fill-800x600x10',
  115. 'fill-800x600-d100',
  116. ]
  117. run_tests = [
  118. # Basic usage
  119. ('fill-800x600', dict(width=1000, height=1000), [
  120. ('crop', ((0, 125, 1000, 875), ), {}),
  121. ('resize', ((800, 600), ), {}),
  122. ]),
  123. # Basic usage with an oddly-sized original image
  124. # This checks for a rounding precision issue (#968)
  125. ('fill-200x200', dict(width=539, height=720), [
  126. ('crop', ((0, 90, 539, 630), ), {}),
  127. ('resize', ((200, 200), ), {}),
  128. ]),
  129. # Closeness shouldn't have any effect when used without a focal point
  130. ('fill-800x600-c100', dict(width=1000, height=1000), [
  131. ('crop', ((0, 125, 1000, 875), ), {}),
  132. ('resize', ((800, 600), ), {}),
  133. ]),
  134. # Should always crop towards focal point. Even if no closeness is set
  135. ('fill-80x60', dict(
  136. width=1000,
  137. height=1000,
  138. focal_point_x=1000,
  139. focal_point_y=500,
  140. focal_point_width=0,
  141. focal_point_height=0,
  142. ), [
  143. # Crop the largest possible crop box towards the focal point
  144. ('crop', ((0, 125, 1000, 875), ), {}),
  145. # Resize it down to final size
  146. ('resize', ((80, 60), ), {}),
  147. ]),
  148. # Should crop as close as possible without upscaling
  149. ('fill-80x60-c100', dict(
  150. width=1000,
  151. height=1000,
  152. focal_point_x=1000,
  153. focal_point_y=500,
  154. focal_point_width=0,
  155. focal_point_height=0,
  156. ), [
  157. # Crop as close as possible to the focal point
  158. ('crop', ((920, 470, 1000, 530), ), {}),
  159. # No need to resize, crop should've created an 80x60 image
  160. ]),
  161. # Ditto with a wide image
  162. # Using a different filter so method name doesn't clash
  163. ('fill-100x60-c100', dict(
  164. width=2000,
  165. height=1000,
  166. focal_point_x=2000,
  167. focal_point_y=500,
  168. focal_point_width=0,
  169. focal_point_height=0,
  170. ), [
  171. # Crop to the right hand side
  172. ('crop', ((1900, 470, 2000, 530), ), {}),
  173. ]),
  174. # Make sure that the crop box never enters the focal point
  175. ('fill-50x50-c100', dict(
  176. width=2000,
  177. height=1000,
  178. focal_point_x=1000,
  179. focal_point_y=500,
  180. focal_point_width=100,
  181. focal_point_height=20,
  182. ), [
  183. # Crop a 100x100 box around the entire focal point
  184. ('crop', ((950, 450, 1050, 550), ), {}),
  185. # Resize it down to 50x50
  186. ('resize', ((50, 50), ), {}),
  187. ]),
  188. # Test that the image is never upscaled
  189. ('fill-1000x800', dict(width=100, height=100), [
  190. ('crop', ((0, 10, 100, 90), ), {}),
  191. ]),
  192. # Test that the crop closeness gets capped to prevent upscaling
  193. ('fill-1000x800-c100', dict(
  194. width=1500,
  195. height=1000,
  196. focal_point_x=750,
  197. focal_point_y=500,
  198. focal_point_width=0,
  199. focal_point_height=0,
  200. ), [
  201. # Crop a 1000x800 square out of the image as close to the
  202. # focal point as possible. Will not zoom too far in to
  203. # prevent upscaling
  204. ('crop', ((250, 100, 1250, 900), ), {}),
  205. ]),
  206. # Test for an issue where a ZeroDivisionError would occur when the
  207. # focal point size, image size and filter size match
  208. # See: #797
  209. ('fill-1500x1500-c100', dict(
  210. width=1500,
  211. height=1500,
  212. focal_point_x=750,
  213. focal_point_y=750,
  214. focal_point_width=1500,
  215. focal_point_height=1500,
  216. ), [
  217. # This operation could probably be optimised out
  218. ('crop', ((0, 0, 1500, 1500), ), {}),
  219. ]),
  220. # A few tests for single pixel images
  221. ('fill-100x100', dict(
  222. width=1,
  223. height=1,
  224. ), [
  225. ('crop', ((0, 0, 1, 1), ), {}),
  226. ]),
  227. # This one once gave a ZeroDivisionError
  228. ('fill-100x150', dict(
  229. width=1,
  230. height=1,
  231. ), [
  232. ('crop', ((0, 0, 1, 1), ), {}),
  233. ]),
  234. ('fill-150x100', dict(
  235. width=1,
  236. height=1,
  237. ), [
  238. ('crop', ((0, 0, 1, 1), ), {}),
  239. ]),
  240. ]
  241. TestFillOperation.setup_test_methods()
  242. class TestMinMaxOperation(ImageOperationTestCase):
  243. operation_class = image_operations.MinMaxOperation
  244. filter_spec_tests = [
  245. ('min-800x600', dict(method='min', width=800, height=600)),
  246. ('max-800x600', dict(method='max', width=800, height=600)),
  247. ]
  248. filter_spec_error_tests = [
  249. 'min',
  250. 'min-800',
  251. 'min-abc',
  252. 'min-800xabc',
  253. 'min-800x600-',
  254. 'min-800x600-c100',
  255. 'min-800x600x10',
  256. ]
  257. run_tests = [
  258. # Basic usage of min
  259. ('min-800x600', dict(width=1000, height=1000), [
  260. ('resize', ((800, 800), ), {}),
  261. ]),
  262. # Basic usage of max
  263. ('max-800x600', dict(width=1000, height=1000), [
  264. ('resize', ((600, 600), ), {}),
  265. ]),
  266. ]
  267. TestMinMaxOperation.setup_test_methods()
  268. class TestWidthHeightOperation(ImageOperationTestCase):
  269. operation_class = image_operations.WidthHeightOperation
  270. filter_spec_tests = [
  271. ('width-800', dict(method='width', size=800)),
  272. ('height-600', dict(method='height', size=600)),
  273. ]
  274. filter_spec_error_tests = [
  275. 'width',
  276. 'width-800x600',
  277. 'width-abc',
  278. 'width-800-c100',
  279. ]
  280. run_tests = [
  281. # Basic usage of width
  282. ('width-400', dict(width=1000, height=500), [
  283. ('resize', ((400, 200), ), {}),
  284. ]),
  285. # Basic usage of height
  286. ('height-400', dict(width=1000, height=500), [
  287. ('resize', ((800, 400), ), {}),
  288. ]),
  289. ]
  290. TestWidthHeightOperation.setup_test_methods()
  291. class TestCacheKey(TestCase):
  292. def test_cache_key(self):
  293. image = Image(width=1000, height=1000)
  294. fil = Filter(spec='max-100x100')
  295. cache_key = fil.get_cache_key(image)
  296. self.assertEqual(cache_key, '')
  297. def test_cache_key_fill_filter(self):
  298. image = Image(width=1000, height=1000)
  299. fil = Filter(spec='fill-100x100')
  300. cache_key = fil.get_cache_key(image)
  301. self.assertEqual(cache_key, '2e16d0ba')
  302. def test_cache_key_fill_filter_with_focal_point(self):
  303. image = Image(
  304. width=1000,
  305. height=1000,
  306. focal_point_width=100,
  307. focal_point_height=100,
  308. focal_point_x=500,
  309. focal_point_y=500,
  310. )
  311. fil = Filter(spec='fill-100x100')
  312. cache_key = fil.get_cache_key(image)
  313. self.assertEqual(cache_key, '0bbe3b2f')
  314. class TestFilter(TestCase):
  315. operation_instance = Mock()
  316. def test_runs_operations(self):
  317. run_mock = Mock()
  318. def run(willow, image, env):
  319. run_mock(willow, image, env)
  320. self.operation_instance.run = run
  321. fil = Filter(spec='operation1|operation2')
  322. image = Image.objects.create(
  323. title="Test image",
  324. file=get_test_image_file(),
  325. )
  326. fil.run(image, BytesIO())
  327. self.assertEqual(run_mock.call_count, 2)
  328. @hooks.register('register_image_operations')
  329. def register_image_operations():
  330. return [
  331. ('operation1', Mock(return_value=TestFilter.operation_instance)),
  332. ('operation2', Mock(return_value=TestFilter.operation_instance))
  333. ]
  334. class TestFormatFilter(TestCase):
  335. def test_jpeg(self):
  336. fil = Filter(spec='width-400|format-jpeg')
  337. image = Image.objects.create(
  338. title="Test image",
  339. file=get_test_image_file(),
  340. )
  341. out = fil.run(image, BytesIO())
  342. self.assertEqual(out.format_name, 'jpeg')
  343. def test_png(self):
  344. fil = Filter(spec='width-400|format-png')
  345. image = Image.objects.create(
  346. title="Test image",
  347. file=get_test_image_file(),
  348. )
  349. out = fil.run(image, BytesIO())
  350. self.assertEqual(out.format_name, 'png')
  351. def test_gif(self):
  352. fil = Filter(spec='width-400|format-gif')
  353. image = Image.objects.create(
  354. title="Test image",
  355. file=get_test_image_file(),
  356. )
  357. out = fil.run(image, BytesIO())
  358. self.assertEqual(out.format_name, 'gif')
  359. def test_invalid(self):
  360. fil = Filter(spec='width-400|format-foo')
  361. image = Image.objects.create(
  362. title="Test image",
  363. file=get_test_image_file(),
  364. )
  365. self.assertRaises(InvalidFilterSpecError, fil.run, image, BytesIO())
  366. class TestJPEGQualityFilter(TestCase):
  367. def test_default_quality(self):
  368. fil = Filter(spec='width-400')
  369. image = Image.objects.create(
  370. title="Test image",
  371. file=get_test_image_file_jpeg(),
  372. )
  373. f = BytesIO()
  374. with patch('PIL.Image.Image.save') as save:
  375. fil.run(image, f)
  376. save.assert_called_with(f, 'JPEG', quality=85, optimize=True, progressive=True)
  377. def test_jpeg_quality_filter(self):
  378. fil = Filter(spec='width-400|jpegquality-40')
  379. image = Image.objects.create(
  380. title="Test image",
  381. file=get_test_image_file_jpeg(),
  382. )
  383. f = BytesIO()
  384. with patch('PIL.Image.Image.save') as save:
  385. fil.run(image, f)
  386. save.assert_called_with(f, 'JPEG', quality=40, optimize=True, progressive=True)
  387. def test_jpeg_quality_filter_invalid(self):
  388. fil = Filter(spec='width-400|jpegquality-abc')
  389. image = Image.objects.create(
  390. title="Test image",
  391. file=get_test_image_file_jpeg(),
  392. )
  393. self.assertRaises(InvalidFilterSpecError, fil.run, image, BytesIO())
  394. def test_jpeg_quality_filter_no_value(self):
  395. fil = Filter(spec='width-400|jpegquality')
  396. image = Image.objects.create(
  397. title="Test image",
  398. file=get_test_image_file_jpeg(),
  399. )
  400. self.assertRaises(InvalidFilterSpecError, fil.run, image, BytesIO())
  401. def test_jpeg_quality_filter_too_big(self):
  402. fil = Filter(spec='width-400|jpegquality-101')
  403. image = Image.objects.create(
  404. title="Test image",
  405. file=get_test_image_file_jpeg(),
  406. )
  407. self.assertRaises(InvalidFilterSpecError, fil.run, image, BytesIO())
  408. @override_settings(
  409. WAGTAILIMAGES_JPEG_QUALITY=50
  410. )
  411. def test_jpeg_quality_setting(self):
  412. fil = Filter(spec='width-400')
  413. image = Image.objects.create(
  414. title="Test image",
  415. file=get_test_image_file_jpeg(),
  416. )
  417. f = BytesIO()
  418. with patch('PIL.Image.Image.save') as save:
  419. fil.run(image, f)
  420. save.assert_called_with(f, 'JPEG', quality=50, optimize=True, progressive=True)
  421. @override_settings(
  422. WAGTAILIMAGES_JPEG_QUALITY=50
  423. )
  424. def test_jpeg_quality_filter_overrides_setting(self):
  425. fil = Filter(spec='width-400|jpegquality-40')
  426. image = Image.objects.create(
  427. title="Test image",
  428. file=get_test_image_file_jpeg(),
  429. )
  430. f = BytesIO()
  431. with patch('PIL.Image.Image.save') as save:
  432. fil.run(image, f)
  433. save.assert_called_with(f, 'JPEG', quality=40, optimize=True, progressive=True)
  434. class TestBackgroundColorFilter(TestCase):
  435. def test_original_has_alpha(self):
  436. # Checks that the test image we're using has alpha
  437. fil = Filter(spec='width-400')
  438. image = Image.objects.create(
  439. title="Test image",
  440. file=get_test_image_file(),
  441. )
  442. out = fil.run(image, BytesIO())
  443. self.assertTrue(out.has_alpha())
  444. def test_3_digit_hex(self):
  445. fil = Filter(spec='width-400|bgcolor-fff')
  446. image = Image.objects.create(
  447. title="Test image",
  448. file=get_test_image_file(),
  449. )
  450. out = fil.run(image, BytesIO())
  451. self.assertFalse(out.has_alpha())
  452. def test_6_digit_hex(self):
  453. fil = Filter(spec='width-400|bgcolor-ffffff')
  454. image = Image.objects.create(
  455. title="Test image",
  456. file=get_test_image_file(),
  457. )
  458. out = fil.run(image, BytesIO())
  459. self.assertFalse(out.has_alpha())
  460. def test_invalid(self):
  461. fil = Filter(spec='width-400|bgcolor-foo')
  462. image = Image.objects.create(
  463. title="Test image",
  464. file=get_test_image_file(),
  465. )
  466. self.assertRaises(ValueError, fil.run, image, BytesIO())