diff --git a/CHANGES.rst b/CHANGES.rst index fb709a8..fef5e71 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -19,6 +19,8 @@ Bug fixes: 3.0.2 (2018-09-28) ------------------ +Changes: + Bug fixes: - Fix cleanup of image scales in py3 diff --git a/news/29.feature b/news/29.feature new file mode 100644 index 0000000..4ffae2e --- /dev/null +++ b/news/29.feature @@ -0,0 +1,5 @@ +The ``mode`` argument replaces the old, now deprecated, ``direction`` argument. +The new names are ``contain`` or ``scale-crop-to-fit`` instead of ``down``, +``cover`` or ``scale-crop-to-fill`` instead of ``up`` +and ``scale`` instead of ``thumbnail``. +[fschulze] diff --git a/plone/scale/scale.py b/plone/scale/scale.py index 4bc414e..279cb62 100644 --- a/plone/scale/scale.py +++ b/plone/scale/scale.py @@ -28,8 +28,8 @@ def none_as_int(the_int): PIL.ImageFile.MAXBLOCK = 1000000 -def scaleImage(image, width=None, height=None, direction='down', - quality=88, result=None): +def scaleImage(image, width=None, height=None, mode='contain', + quality=88, result=None, direction=None): """Scale the given image data to another size and return the result as a string or optionally write in to the file-like `result` object. @@ -43,7 +43,7 @@ def scaleImage(image, width=None, height=None, direction='down', a size-tuple. Optionally a file-like object can be given as the `result` parameter, in which the generated image scale will be stored. - The `width`, `height`, `direction` parameters will be passed to + The `width`, `height`, `mode` parameters will be passed to :meth:`scalePILImage`, which performs the actual scaling. The generated image is a JPEG image, unless the original is a PNG or GIF @@ -64,7 +64,7 @@ def scaleImage(image, width=None, height=None, direction='down', format_ = 'PNG' icc_profile = image.info.get('icc_profile') - image = scalePILImage(image, width, height, direction) + image = scalePILImage(image, width, height, mode, direction=direction) # convert to simpler mode if possible colors = image.getcolors(maxcolors=256) @@ -129,31 +129,30 @@ def _scale_thumbnail(image, width=None, height=None): return image -def scalePILImage(image, width=None, height=None, direction='down'): +def scalePILImage(image, width=None, height=None, mode='contain', direction=None): """Scale a PIL image to another size. This is all about scaling for the display in a web browser. Either width or height - or both - must be given. - Three different scaling options are supported via `direction`: + Three different scaling options are supported via `mode` and correspond to + the CSS background-size values + (see https://developer.mozilla.org/en-US/docs/Web/CSS/background-size): - `up` - scaling scales the smallest dimension up to the required size - and crops the other dimension if needed. - - `down` + `contain` or `scale-crop-to-fit` scaling starts by scaling the largest dimension to the required size and crops the other dimension if needed. - `thumbnail` - scales to the requested dimensions without cropping. Theresulting + `cover` or `scale-crop-to-fill` + scaling scales the smallest dimension up to the required size + and crops the other dimension if needed. + + `scale` + scales to the requested dimensions without cropping. The resulting image may have a different size than requested. This option requires both width and height to be specified. - `keep` is accepted as an alternative spelling for this option, - but its use is deprecated. - The `image` parameter must be an instance of the `PIL.Image` class. The return value the scaled image in the form of another instance of @@ -167,12 +166,34 @@ def scalePILImage(image, width=None, height=None, direction='down'): if width is None and height is None: raise ValueError("Either width or height need to be given") - if direction == "keep": + if direction is not None: + warnings.warn( + "the 'direction' option is deprecated, use 'mode' instead", + DeprecationWarning) + mode = direction + del direction + + if mode == "down": + warnings.warn( + "the 'down' scaling mode is deprecated, use 'contain' instead", + DeprecationWarning) + mode = "contain" + if mode == "up": + warnings.warn( + "the 'up' scaling mode is deprecated, use 'cover' instead", + DeprecationWarning) + mode = "cover" + if mode == "thumbnail": warnings.warn( - 'direction="keep" is deprecated, use "thumbnail" instead', - DeprecationWarning - ) - direction = "thumbnail" + "the 'thumbnail' scaling mode is deprecated, use 'scale' instead", + DeprecationWarning) + mode = "scale" + if mode == "scale-crop-to-fit": + mode = "contain" + if mode == "scale-crop-to-fill": + mode = "cover" + if mode not in ('contain', 'cover', 'scale'): + raise ValueError("Unknown scale mode '%s'" % mode) if image.mode == "1": # Convert black&white to grayscale @@ -189,11 +210,11 @@ def scalePILImage(image, width=None, height=None, direction='down'): # Convert CMYK to RGB, allowing for web previews of print images image = image.convert("RGB") - # for thumbnail we're done: - if direction == 'thumbnail': + # for scale we're done: + if mode == 'scale': return _scale_thumbnail(image, width, height) - # now for up and down scaling + # now for cover and contain scaling # Determine scale factor needed to get the right height factor_height = factor_width = None if height is not None: @@ -204,15 +225,15 @@ def scalePILImage(image, width=None, height=None, direction='down'): if factor_height == factor_width: # The original already has the right aspect ratio, so we only need # to scale. - if direction == 'down': + if mode == 'contain': image.thumbnail((width, height), PIL.Image.ANTIALIAS) return image return image.resize((width, height), PIL.Image.ANTIALIAS) # figure out which axis to scale. One of the factors can still be None! - # calculate for 'down' + # calculate for 'contain' use_height = none_as_int(factor_width) > none_as_int(factor_height) - if direction == 'up': # for 'up': invert + if mode == 'cover': # for 'cover': invert use_height = not use_height new_width = width diff --git a/plone/scale/tests/test_scale.py b/plone/scale/tests/test_scale.py index 58177f7..30ea5b7 100644 --- a/plone/scale/tests/test_scale.py +++ b/plone/scale/tests/test_scale.py @@ -6,6 +6,8 @@ import os.path import PIL.Image import PIL.ImageDraw +import warnings + try: from cStringIO import StringIO @@ -28,19 +30,19 @@ class ScalingTests(TestCase): def testNewSizeReturned(self): - (imagedata, format, size) = scaleImage(PNG, 42, 51, "down") + (imagedata, format, size) = scaleImage(PNG, 42, 51, "contain") input = StringIO(imagedata) image = PIL.Image.open(input) self.assertEqual(image.size, size) def testScaledImageKeepPNG(self): - self.assertEqual(scaleImage(PNG, 84, 103, "down")[1], "PNG") + self.assertEqual(scaleImage(PNG, 84, 103, "contain")[1], "PNG") def testScaledImageKeepGIFto(self): - self.assertEqual(scaleImage(GIF, 84, 103, "down")[1], "PNG") + self.assertEqual(scaleImage(GIF, 84, 103, "contain")[1], "PNG") def testScaledImageIsJpeg(self): - self.assertEqual(scaleImage(TIFF, 84, 103, "down")[1], "JPEG") + self.assertEqual(scaleImage(TIFF, 84, 103, "contain")[1], "JPEG") def testAlphaForcesPNG(self): # first image without alpha @@ -50,7 +52,7 @@ def testAlphaForcesPNG(self): src.putpixel((x, y), (x, y, 0, 255)) result = StringIO() src.save(result, "TIFF") - self.assertEqual(scaleImage(result, 84, 103, "down")[1], "JPEG") + self.assertEqual(scaleImage(result, 84, 103, "contain")[1], "JPEG") # now with alpha src = PIL.Image.new("RGBA", (256, 256), (255, 255, 255, 128)) result = StringIO() @@ -58,25 +60,25 @@ def testAlphaForcesPNG(self): for x in range(0, 256): src.putpixel((x, y), (x, y, 0, x)) src.save(result, "TIFF") - self.assertEqual(scaleImage(result, 84, 103, "down")[1], "PNG") + self.assertEqual(scaleImage(result, 84, 103, "contain")[1], "PNG") def testScaledCMYKIsRGB(self): - (imagedata, format, size) = scaleImage(CMYK, 42, 51, "down") + (imagedata, format, size) = scaleImage(CMYK, 42, 51, "contain") input = StringIO(imagedata) image = PIL.Image.open(input) self.assertEqual(image.mode, "RGB") def testScaledPngImageIsPng(self): - self.assertEqual(scaleImage(PNG, 84, 103, "down")[1], "PNG") + self.assertEqual(scaleImage(PNG, 84, 103, "contain")[1], "PNG") def testScaledPreservesProfile(self): - (imagedata, format, size) = scaleImage(PROFILE, 42, 51, "down") + (imagedata, format, size) = scaleImage(PROFILE, 42, 51, "contain") input = StringIO(imagedata) image = PIL.Image.open(input) self.assertIsNotNone(image.info.get('icc_profile')) def testScaleWithFewColorsStaysColored(self): - (imagedata, format, size) = scaleImage(PROFILE, 16, None, "down") + (imagedata, format, size) = scaleImage(PROFILE, 16, None, "contain") image = PIL.Image.open(StringIO(imagedata)) self.assertEqual(max(image.size), 16) self.assertEqual(image.mode, 'RGB') @@ -89,7 +91,7 @@ def testAutomaticGreyscale(self): draw.line(((0, i), (256, i)), fill=(i, i, i)) result = StringIO() src.save(result, "JPEG") - (imagedata, format, size) = scaleImage(result, 200, None, "down") + (imagedata, format, size) = scaleImage(result, 200, None, "contain") image = PIL.Image.open(StringIO(imagedata)) self.assertEqual(max(image.size), 200) self.assertEqual(image.mode, 'L') @@ -110,81 +112,79 @@ def testAutomaticPalette(self): self.assertEqual(png.format, 'PNG') self.assertIsNone(png.getcolors(maxcolors=256)) # scale it to a size where we get less than 256 colors - (imagedata, format, size) = scaleImage( - dst.getvalue(), 24, None, "down" - ) + (imagedata, format, size) = scaleImage(dst.getvalue(), 24, None, "contain") image = PIL.Image.open(StringIO(imagedata)) # we should now have an image in palette mode self.assertEqual(image.mode, 'P') self.assertEqual(image.format, 'PNG') def testSameSizeDownScale(self): - self.assertEqual(scaleImage(PNG, 84, 103, "down")[2], (84, 103)) + self.assertEqual(scaleImage(PNG, 84, 103, "contain")[2], (84, 103)) def testHalfSizeDownScale(self): - self.assertEqual(scaleImage(PNG, 42, 51, "down")[2], (42, 51)) + self.assertEqual(scaleImage(PNG, 42, 51, "contain")[2], (42, 51)) def testScaleWithCropDownScale(self): - self.assertEqual(scaleImage(PNG, 20, 51, "down")[2], (20, 51)) + self.assertEqual(scaleImage(PNG, 20, 51, "contain")[2], (20, 51)) def testNoStretchingDownScale(self): - self.assertEqual(scaleImage(PNG, 200, 103, "down")[2], (200, 103)) + self.assertEqual(scaleImage(PNG, 200, 103, "contain")[2], (200, 103)) def testHugeScale(self): # the image will be cropped, but not scaled - self.assertEqual(scaleImage(PNG, 400, 99999, "down")[2], (2, 103)) + self.assertEqual(scaleImage(PNG, 400, 99999, "contain")[2], (2, 103)) def testCropPreWideScaleUnspecifiedHeight(self): - image = scaleImage(PNG, 400, None, "down") + image = scaleImage(PNG, 400, None, "contain") self.assertEqual(image[2], (400, 490)) def testCropPreWideScale(self): - image = scaleImage(PNG, 400, 100, "down") + image = scaleImage(PNG, 400, 100, "contain") self.assertEqual(image[2], (400, 100)) def testCropPreTallScaleUnspecifiedWidth(self): - image = scaleImage(PNG, None, 400, "down") + image = scaleImage(PNG, None, 400, "contain") self.assertEqual(image[2], (326, 400)) def testCropPreTallScale(self): - image = scaleImage(PNG, 100, 400, "down") + image = scaleImage(PNG, 100, 400, "contain") self.assertEqual(image[2], (100, 400)) def testRestrictWidthOnlyDownScaleNone(self): - self.assertEqual(scaleImage(PNG, 42, None, "down")[2], (42, 52)) + self.assertEqual(scaleImage(PNG, 42, None, "contain")[2], (42, 52)) def testRestrictWidthOnlyDownScaleZero(self): - self.assertEqual(scaleImage(PNG, 42, 0, "down")[2], (42, 52)) + self.assertEqual(scaleImage(PNG, 42, 0, "contain")[2], (42, 52)) def testRestrictHeightOnlyDownScaleNone(self): - self.assertEqual(scaleImage(PNG, None, 51, "down")[2], (42, 51)) + self.assertEqual(scaleImage(PNG, None, 51, "contain")[2], (42, 51)) def testRestrictHeightOnlyDownScaleZero(self): - self.assertEqual(scaleImage(PNG, 0, 51, "down")[2], (42, 51)) + self.assertEqual(scaleImage(PNG, 0, 51, "contain")[2], (42, 51)) def testSameSizeUpScale(self): - self.assertEqual(scaleImage(PNG, 84, 103, "up")[2], (84, 103)) + self.assertEqual(scaleImage(PNG, 84, 103, "cover")[2], (84, 103)) def testDoubleSizeUpScale(self): - self.assertEqual(scaleImage(PNG, 168, 206, "up")[2], (168, 206)) + self.assertEqual(scaleImage(PNG, 168, 206, "cover")[2], (168, 206)) def testHalfSizeUpScale(self): - self.assertEqual(scaleImage(PNG, 42, 51, "up")[2], (42, 51)) + self.assertEqual(scaleImage(PNG, 42, 51, "cover")[2], (42, 51)) def testNoStretchingUpScale(self): - self.assertEqual(scaleImage(PNG, 200, 103, "up")[2], (84, 103)) + self.assertEqual(scaleImage(PNG, 200, 103, "cover")[2], (84, 103)) def testRestrictWidthOnlyUpScaleNone(self): - self.assertEqual(scaleImage(PNG, 42, None, "up")[2], (42, 52)) + self.assertEqual(scaleImage(PNG, 42, None, "cover")[2], (42, 52)) def testRestrictWidthOnlyUpScaleZero(self): - self.assertEqual(scaleImage(PNG, 42, 0, "up")[2], (42, 52)) + self.assertEqual(scaleImage(PNG, 42, 0, "cover")[2], (42, 52)) def testRestrictHeightOnlyUpScaleNone(self): - self.assertEqual(scaleImage(PNG, None, 51, "up")[2], (42, 51)) + self.assertEqual(scaleImage(PNG, None, 51, "cover")[2], (42, 51)) def testRestrictHeightOnlyUpScaleZero(self): - self.assertEqual(scaleImage(PNG, 0, 51, "up")[2], (42, 51)) + self.assertEqual(scaleImage(PNG, 0, 51, "cover")[2], (42, 51)) def testNoRestrictionsNone(self): self.assertRaises(ValueError, scaleImage, PNG, None, None) @@ -193,16 +193,13 @@ def testNoRestrictionsZero(self): self.assertRaises(ValueError, scaleImage, PNG, 0, 0) def testKeepAspectRatio(self): - self.assertEqual(scaleImage(PNG, 80, 80, "thumbnail")[2], (65, 80)) - - def testKeepAspectRatioBBB(self): - self.assertEqual(scaleImage(PNG, 80, 80, "keep")[2], (65, 80)) + self.assertEqual(scaleImage(PNG, 80, 80, "scale")[2], (65, 80)) def testThumbnailHeightNone(self): - self.assertEqual(scaleImage(PNG, 42, None, "thumbnail")[2], (42, 51)) + self.assertEqual(scaleImage(PNG, 42, None, "scale")[2], (42, 51)) def testThumbnailWidthNone(self): - self.assertEqual(scaleImage(PNG, None, 51, "thumbnail")[2], (41, 51)) + self.assertEqual(scaleImage(PNG, None, 51, "scale")[2], (41, 51)) def testQuality(self): img1 = scaleImage(CMYK, 84, 103)[0] @@ -219,7 +216,48 @@ def testResultBuffer(self): self.assertEqual(result, img2) # the return value _is_ the buffer self.assertEqual(result.getvalue(), img1) # but with the same value + def testDeprecations(self): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + scaleImage(PNG, 16, 16, "down") + self.assertEqual(len(w), 1) + self.assertIs(w[0].category, DeprecationWarning) + self.assertIn( + "the 'down' scaling mode is deprecated", + str(w[0].message)) + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + scaleImage(PNG, 16, 16, "up") + self.assertEqual(len(w), 1) + self.assertIs(w[0].category, DeprecationWarning) + self.assertIn( + "the 'up' scaling mode is deprecated", + str(w[0].message)) + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + scaleImage(PNG, 16, 16, "thumbnail") + self.assertEqual(len(w), 1) + self.assertIs(w[0].category, DeprecationWarning) + self.assertIn( + "the 'thumbnail' scaling mode is deprecated", + str(w[0].message)) + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + scaleImage(PNG, 16, 16, direction="up") + self.assertEqual(len(w), 2) + self.assertIs(w[0].category, DeprecationWarning) + self.assertIn( + "the 'direction' option is deprecated", + str(w[0].message)) + self.assertIs(w[1].category, DeprecationWarning) + self.assertIn( + "the 'up' scaling mode is deprecated", + str(w[1].message)) + def test_suite(): from unittest import defaultTestLoader + from warnings import filterwarnings + filterwarnings("error", "the 'direction' option is deprecated") + filterwarnings("error", "the '.*' scaling mode is deprecated") return defaultTestLoader.loadTestsFromName(__name__) diff --git a/plone/scale/tests/test_storage.py b/plone/scale/tests/test_storage.py index fda5de7..6c8524c 100644 --- a/plone/scale/tests/test_storage.py +++ b/plone/scale/tests/test_storage.py @@ -256,4 +256,7 @@ def testClear(self): def test_suite(): from unittest import defaultTestLoader + from warnings import filterwarnings + filterwarnings("error", "the 'direction' option is deprecated") + filterwarnings("error", "the '.*' scaling mode is deprecated") return defaultTestLoader.loadTestsFromName(__name__)