diff --git a/docs/changes.rst b/docs/changes.rst index 42120221..a5e9eef5 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -9,15 +9,31 @@ Wand Changelog Version 0.4.1 ------------- - - Added ``gravity`` options in :meth:`Image.crop() ` method. - [:issue:`222` by Eric McConville] +- Added :meth:`Image.auto_orient() ` that fixes orientation by checking EXIF tags +- Added :meth:`Image.transverse() ` transverse (MagickTransverseImage) +- Added :meth:`Image.transpose() ` transpose (MagickTransposeImage) +- Added :meth:`Image.frame() ` method. +- Added :meth:`Image.function() ` method. +- Added :meth:`Image.fx() ` expression method. +- Added ``gravity`` options in :meth:`Image.crop() ` method. + [:issue:`222` by Eric McConville] +- Added :meth:`Image.matte() ` method for toggling image matte channel. +- Added :attr:`Image.matte_color ` property. +- Added :attr:`Image.virtual_pixel ` property. +- Added :meth:`Image.distort() ` method. +- Additional query functions have been added to :mod:`wand.version` API. [:issue:`120`] + + - Added :func:`configure_options() ` function. + - Added :func:`fonts() ` function. + - Added :func:`formats() ` function. + - Additional IPython support. [:issue:`117`] - Render RGB :class:`Color ` preview. - Display each frame in image :class:`Sequence `. +- Fixed Windows memory-deallocate errors on :mod:`wand.drawing` API. [:issue:`226` by Eric McConville] -.. _changelog-0.4.0: Version 0.4.0 ------------- diff --git a/docs/guide/transform.rst b/docs/guide/transform.rst index 1a635cfd..8e777641 100644 --- a/docs/guide/transform.rst +++ b/docs/guide/transform.rst @@ -99,40 +99,3 @@ The image :file:`transform-flopped.jpg` generated by the above code looks like: .. image:: ../_images/transform-flopped.jpg :alt: transform-flopped.jpg - -Chroma Key with FX Expressions ------------------------------- - -.. versionadded:: 0.4.1 - -The :meth:`Image.fx() ` method is a powerful -tool for evaluating an image's pixel data, and creating an image mask. -Green screen, or Chroma-key compositing, is a common post-production -task for manipulating image data, and can be done with FX expressions. - -A transparent mask can be calculated by applying the following -expression:: - - alpha(red, green, blue) = K1 * blue - K2 * green + K3 - -Where ``K1``, ``K2`` & ``K3`` are user-defined constants. - -.. image:: ../_images/chroma-key.png - :alt: chroma-key.png - -This example will assume the value of ``1.0`` for each constant, as the *lime* -color will be keyed.:: - - from wand.image import Image - - with Image(filename='chroma.png') as image: - expression = '{k1} * b - {k2} * g + {k3}'.format(k1=1.0, - k2=1.0, - k3=1.0) - with image.fx(expression) as mask: - image.composite_channel(channel='alpha', - operator='copy_opacity', - image=mask) - -.. image:: ../_images/chroma-keyed.png - :alt: chroma-keyed.png \ No newline at end of file diff --git a/tests/assets/orientationtest.jpg b/tests/assets/orientationtest.jpg new file mode 100644 index 00000000..5636c202 Binary files /dev/null and b/tests/assets/orientationtest.jpg differ diff --git a/tests/drawing_test.py b/tests/drawing_test.py index 0f26f13a..eb31ae96 100644 --- a/tests/drawing_test.py +++ b/tests/drawing_test.py @@ -42,7 +42,7 @@ def test_set_get_font(fx_wand, fx_asset): assert fx_wand.font == str(fx_asset.join('League_Gothic.otf')) def test_set_get_font_family(fx_wand): - assert fx_wand.font_family == '' + assert fx_wand.font_family is None fx_wand.font_family = 'sans-serif' assert fx_wand.font_family == 'sans-serif' diff --git a/tests/image_test.py b/tests/image_test.py index 7dac5afd..2fd99158 100644 --- a/tests/image_test.py +++ b/tests/image_test.py @@ -13,7 +13,7 @@ from wand.image import ClosedImageError, Image from wand.color import Color from wand.compat import PY3, string_type, text, text_type -from wand.exceptions import MissingDelegateError +from wand.exceptions import OptionError, MissingDelegateError from wand.font import Font @@ -320,6 +320,21 @@ def test_set_units(fx_asset): assert img.units == "pixelspercentimeter" +def test_get_virtual_pixel(fx_asset): + """Gets image virtual pixel""" + with Image(filename=str(fx_asset.join('mona-lisa.jpg'))) as img: + assert img.virtual_pixel == "undefined" + + +def test_set_virtual_pixel(fx_asset): + """Sets image virtual pixel""" + with Image(filename=str(fx_asset.join('mona-lisa.jpg'))) as img: + img.virtual_pixel = "tile" + assert img.virtual_pixel == "tile" + with raises(ValueError): + img.virtual_pixel = "nothing" + + def test_get_colorspace(fx_asset): """Gets the image colorspace""" with Image(filename=str(fx_asset.join('mona-lisa.jpg'))) as img: @@ -689,6 +704,28 @@ def test_crop_gravity_error(fx_asset): img.crop(width=1, height=1, gravity='nowhere') +@mark.slow +def test_distort(fx_asset): + """Distort image.""" + with Image(filename=str(fx_asset.join('mona-lisa.jpg'))) as img: + with Color('skyblue') as color: + img.matte_color = color + img.virtual_pixel = 'tile' + img.distort('perspective', (0, 0, 20, 60, 90, 0, + 70, 63, 0, 90, 5, 83, + 90, 90, 85, 88)) + assert img[img.width - 1, 0] == color + + +def test_distort_error(fx_asset): + """Distort image with user error""" + with Image(filename=str(fx_asset.join('mona-lisa.jpg'))) as img: + with raises(ValueError): + img.distort('mirror', (1,)) + with raises(TypeError): + img.distort('perspective', 1) + + @mark.parametrize(('method'), [ ('resize'), ('sample'), @@ -970,6 +1007,22 @@ def test_set_background_color(fx_asset): assert img.background_color == color +def test_set_get_matte_color(fx_asset): + with Image(filename='rose:') as img: + with Color('navy') as color: + img.matte_color = color + assert img.matte_color == color + with raises(TypeError): + img.matte_color = False + + +def test_set_matte(fx_asset): + with Image(filename='rose:') as img: + img.matte(True) + img.matte(False) + with raises(TypeError): + img.matte('true') + def test_transparentize(fx_asset): with Image(filename=str(fx_asset.join('croptest.png'))) as im: with Color('transparent') as transparent: @@ -1306,6 +1359,94 @@ def test_flop(fx_asset): assert flopped[-1, -1] == img[0, -1] +def test_frame(fx_asset): + with Image(filename=str(fx_asset.join('mona-lisa.jpg'))) as img: + img.frame(width=4, height=4) + assert img[0, 0] == img[-1, -1] + assert img[-1, 0] == img[0, -1] + with Color('green') as green: + with Image(filename=str(fx_asset.join('mona-lisa.jpg'))) as img: + img.frame(matte=green, width=2, height=2) + assert img[0, 0] == green + assert img[-1, -1] == green + + +def test_frame_error(fx_asset): + with Image(filename=str(fx_asset.join('mona-lisa.jpg'))) as img: + with raises(TypeError): + img.frame(width='one') + with raises(TypeError): + img.frame(height=3.5) + with raises(TypeError): + img.frame(matte='green') + with raises(TypeError): + img.frame(inner_bevel=None) + with raises(TypeError): + img.frame(outer_bevel='large') + + +def test_function(fx_asset): + with Image(filename=str(fx_asset.join('croptest.png'))) as img: + img.function(function='polynomial', + arguments=(4, -4, 1)) + assert img[150, 150] == Color('white') + img.function(function='sinusoid', + arguments=(1,), + channel='red') + assert abs(img[150, 150].red - Color('#80FFFF').red) < 0.01 + + +def test_function_error(fx_asset): + with Image(filename=str(fx_asset.join('croptest.png'))) as img: + with raises(ValueError): + img.function('bad function', 1) + with raises(TypeError): + img.function('sinusoid', 1) + with raises(ValueError): + img.function('sinusoid', (1,), channel='bad channel') + + +def test_fx(fx_asset): + with Image(width=2, height=2, background=Color('black')) as xc1: + # NavyBlue == #000080 + with xc1.fx('0.5019', channel='blue') as xc2: + assert abs(xc2[0, 0].blue - Color('navy').blue) < 0.0001 + + with Image(width=2, height=1, background=Color('white')) as xc1: + with xc1.fx('0') as xc2: + assert xc2[0, 0].red == 0 + + +def test_fx_error(fx_asset): + with Image() as empty_wand: + with raises(AttributeError): + with empty_wand.fx('8'): + pass + with Image(filename='rose:') as xc: + with raises(OptionError): + with xc.fx('/0'): + pass + with raises(TypeError): + with xc.fx(('p[0,0]',)): + pass + with raises(ValueError): + with xc.fx('p[0,0]', True): + pass + +def test_transpose(fx_asset): + with Image(filename=str(fx_asset.join('beach.jpg'))) as img: + with img.clone() as transposed: + transposed.transpose() + assert transposed[501, 501] == Color('srgb(205,196,179)') + + +def test_transverse(fx_asset): + with Image(filename=str(fx_asset.join('beach.jpg'))) as img: + with img.clone() as transversed: + transversed.transverse() + assert transversed[500, 500] == Color('srgb(96,136,185)') + + def test_get_orientation(fx_asset): with Image(filename=str(fx_asset.join('sasha.jpg'))) as img: assert img.orientation == 'undefined' @@ -1320,6 +1461,31 @@ def test_set_orientation(fx_asset): assert img.orientation == 'bottom_right' +def test_auto_orientation(fx_asset): + with Image(filename=str(fx_asset.join('beach.jpg'))) as img: + # if orientation is undefined nothing should be changed + before = img[100, 100] + img.auto_orient() + after = img[100, 100] + assert before == after + assert img.orientation == 'top_left' + + with Image(filename=str(fx_asset.join('orientationtest.jpg'))) as original: + with original.clone() as img: + # now we should get a flipped image + assert img.orientation == 'bottom_left' + before = img[100, 100] + img.auto_orient() + after = img[100, 100] + assert before != after + assert img.orientation == 'top_left' + + assert img[0, 0] == original[0, -1] + assert img[0, -1] == original[0, 0] + assert img[-1, 0] == original[-1, -1] + assert img[-1, -1] == original[-1, 0] + + def test_histogram(fx_asset): with Image(filename=str(fx_asset.join('trim-color-test.png'))) as a: h = a.histogram diff --git a/tests/misc_test.py b/tests/misc_test.py index 65b594b3..e20eb561 100644 --- a/tests/misc_test.py +++ b/tests/misc_test.py @@ -1,11 +1,12 @@ import datetime import numbers import re +from py.test import mark from wand.version import (MAGICK_VERSION, MAGICK_VERSION_INFO, MAGICK_VERSION_NUMBER, MAGICK_RELEASE_DATE, - MAGICK_RELEASE_DATE_STRING, QUANTUM_DEPTH) - + MAGICK_RELEASE_DATE_STRING, QUANTUM_DEPTH, + configure_options, fonts, formats) def test_version(): """Test version strings.""" @@ -24,3 +25,20 @@ def test_version(): def test_quantum_depth(): """QUANTUM_DEPTH must be one of 8, 16, 32, or 64.""" assert QUANTUM_DEPTH in (8, 16, 32, 64) + + +def test_configure_options(): + assert 'RELEASE_DATE' in configure_options('RELEASE_DATE') + + +def test_fonts(): + font_list = fonts() + mark.skipif(not font_list, reason='Fonts not configured on system') + first_font = font_list[0] + first_font_part = first_font[1:-1] + assert first_font in fonts('*{0}*'.format(first_font_part)) + + +def test_formats(): + xc = 'XC' + assert formats(xc) == [xc] diff --git a/wand/api.py b/wand/api.py index d9fec40b..40e12d10 100644 --- a/wand/api.py +++ b/wand/api.py @@ -253,6 +253,14 @@ class AffineMatrix(ctypes.Structure): library.MagickSetImageBackgroundColor.argtypes = [ctypes.c_void_p, ctypes.c_void_p] + library.MagickSetImageMatte.argtypes = [ctypes.c_void_p, ctypes.c_int] + + library.MagickGetImageMatteColor.argtypes = [ctypes.c_void_p, + ctypes.c_void_p] + + library.MagickSetImageMatteColor.argtypes = [ctypes.c_void_p, + ctypes.c_void_p] + library.MagickGetImageAlphaChannel.argtypes = [ctypes.c_void_p] library.MagickGetImageAlphaChannel.restype = ctypes.c_size_t @@ -305,6 +313,10 @@ class AffineMatrix(ctypes.Structure): library.MagickSetImageUnits.argtypes = [ctypes.c_void_p, ctypes.c_int] + library.MagickGetImageVirtualPixelMethod.argtypes = [ctypes.c_void_p] + + library.MagickSetImageVirtualPixelMethod.argtypes = [ctypes.c_void_p, ctypes.c_int] + library.MagickGetImageColorspace.argtypes = [ctypes.c_void_p] library.MagickGetImageColorspace.restype = ctypes.c_int @@ -336,6 +348,34 @@ class AffineMatrix(ctypes.Structure): library.MagickFlopImage.argtypes = [ctypes.c_void_p] + library.MagickFrameImage.argtypes = [ctypes.c_void_p, # wand + ctypes.c_void_p, # matte_color + ctypes.c_size_t, # width + ctypes.c_size_t, # height + ctypes.c_ssize_t, # inner_bevel + ctypes.c_ssize_t] # outer_bevel + + library.MagickFunctionImage.argtypes = [ctypes.c_void_p, # wand + ctypes.c_int, # MagickFunction + ctypes.c_size_t, # number_arguments + ctypes.POINTER(ctypes.c_double)] # arguments + + library.MagickFunctionImageChannel.argtypes = [ctypes.c_void_p, # wand + ctypes.c_int, # channel + ctypes.c_int, # MagickFunction + ctypes.c_size_t, # number_arguments + ctypes.POINTER(ctypes.c_double)] # arguments + + library.MagickFxImage.argtypes = [ctypes.c_void_p, # wand + ctypes.c_char_p] # expression + library.MagickFxImage.restype = ctypes.c_void_p + + library.MagickFxImageChannel.argtypes = [ctypes.c_void_p, # wand + ctypes.c_int, # channel + ctypes.c_char_p] # expression + library.MagickFxImageChannel.restype = ctypes.c_void_p + + library.MagickResetImagePage.argtypes = [ctypes.c_void_p, ctypes.c_char_p] library.MagickSampleImage.argtypes = [ctypes.c_void_p, ctypes.c_size_t, @@ -790,7 +830,7 @@ class AffineMatrix(ctypes.Structure): ctypes.c_void_p] # PixelWand color library.DrawGetClipPath.argtypes = [ctypes.c_void_p] - library.DrawGetClipPath.restype = ctypes.c_void_p + library.DrawGetClipPath.restype = c_magick_char_p library.DrawGetClipRule.argtypes = [ctypes.c_void_p] library.DrawGetClipRule.restype = ctypes.c_uint @@ -839,10 +879,10 @@ class AffineMatrix(ctypes.Structure): library.DrawGetStrokeWidth.restype = ctypes.c_double library.DrawGetFont.argtypes = [ctypes.c_void_p] - library.DrawGetFont.restype = ctypes.c_void_p + library.DrawGetFont.restype = c_magick_char_p library.DrawGetFontFamily.argtypes = [ctypes.c_void_p] - library.DrawGetFontFamily.restype = ctypes.c_void_p + library.DrawGetFontFamily.restype = c_magick_char_p library.DrawGetFontResolution.argtypes = [ctypes.c_void_p, #wand ctypes.POINTER(ctypes.c_double), # x @@ -877,7 +917,7 @@ class AffineMatrix(ctypes.Structure): library.DrawGetTextDirection = None library.DrawGetTextEncoding.argtypes = [ctypes.c_void_p] - library.DrawGetTextEncoding.restype = ctypes.c_void_p + library.DrawGetTextEncoding.restype = c_magick_char_p try: library.DrawGetTextInterlineSpacing.argtypes = [ctypes.c_void_p] @@ -895,7 +935,7 @@ class AffineMatrix(ctypes.Structure): ctypes.c_void_p] library.DrawGetVectorGraphics.argtypes = [ctypes.c_void_p] - library.DrawGetVectorGraphics.restype = ctypes.c_void_p + library.DrawGetVectorGraphics.restype = c_magick_char_p library.DrawSetGravity.argtypes = [ctypes.c_void_p, ctypes.c_int] @@ -911,6 +951,14 @@ class AffineMatrix(ctypes.Structure): ctypes.c_char_p] library.MagickAnnotateImage.restype = ctypes.c_int + library.MagickDistortImage.argtypes = [ctypes.c_void_p, # wand + ctypes.c_int, # method + ctypes.c_size_t, # number_arguments + ctypes.POINTER(ctypes.c_double), # arguments + ctypes.c_int] # bestfit + library.MagickDistortImage.restype = ctypes.c_int + + library.ClearDrawingWand.argtypes = [ctypes.c_void_p] library.MagickDrawImage.argtypes = [ctypes.c_void_p, @@ -1139,11 +1187,26 @@ class AffineMatrix(ctypes.Structure): library.MagickEqualizeImage.argtypes = [ctypes.c_void_p] + library.MagickQueryConfigureOption.argtypes = [ctypes.c_char_p] + library.MagickQueryConfigureOption.restype = c_magick_char_p + + library.MagickQueryConfigureOptions.argtypes = [ctypes.c_char_p, + ctypes.POINTER(ctypes.c_size_t)] + library.MagickQueryConfigureOptions.restype = ctypes.POINTER(c_magick_char_p) + library.MagickQueryFontMetrics.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_char_p] library.MagickQueryFontMetrics.restype = ctypes.POINTER(ctypes.c_double) + library.MagickQueryFonts.argtypes = [ctypes.c_char_p, + ctypes.POINTER(ctypes.c_size_t)] + library.MagickQueryFonts.restype = ctypes.POINTER(c_magick_char_p) + + library.MagickQueryFormats.argtypes = [ctypes.c_char_p, + ctypes.POINTER(ctypes.c_size_t)] + library.MagickQueryFormats.restype = ctypes.POINTER(c_magick_char_p) + library.MagickQueryMultilineFontMetrics.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_char_p] @@ -1166,11 +1229,23 @@ class AffineMatrix(ctypes.Structure): ctypes.c_int] library.MagickAppendImages.restype = ctypes.c_void_p + library.MagickTransposeImage.argtypes = [ctypes.c_void_p] + library.MagickTransverseImage.argtypes = [ctypes.c_void_p] + + except AttributeError: raise ImportError('MagickWand shared library not found or incompatible\n' 'Original exception was raised in:\n' + traceback.format_exc()) +try: + library.MagickAutoOrientImage.argtypes = [ctypes.c_void_p] +except AttributeError: + # MagickAutoOrientImage was added in 6.8.9+, we have a fallback function + # so we pass silently if we cant import it + pass + + #: (:class:`ctypes.CDLL`) The C standard library. libc = None diff --git a/wand/drawing.py b/wand/drawing.py index 0c3d9015..5d601661 100644 --- a/wand/drawing.py +++ b/wand/drawing.py @@ -10,7 +10,7 @@ import ctypes import numbers -from .api import library, libc, MagickPixelPacket, PointInfo, AffineMatrix +from .api import library, MagickPixelPacket, PointInfo, AffineMatrix from .color import Color from .compat import binary, string_type, text, text_type, xrange from .image import Image, COMPOSITE_OPERATORS @@ -146,24 +146,6 @@ 'floodfill', 'filltoborder', 'reset') -def leaky_string(func): - """Decorator method to convert MagickWand's dynamically allocated - char buffers to python strings. Ensures any returned non-None pointer is - freed with libc's method. - - .. versionadded:: 0.4.0 - """ - def wrapper(*args, **kwargs): - c_void_p = func(*args, **kwargs) - string = text('') - if c_void_p is not None: - c_void_p = ctypes.cast(c_void_p, ctypes.c_char_p) - string = text(c_void_p.value) - libc.free(c_void_p) - return string - return wrapper - - class Drawing(Resource): """Drawing object. It maintains several vector drawing instructions and can get drawn into zero or more :class:`~wand.image.Image` objects @@ -233,14 +215,15 @@ def border_color(self, border_color): library.DrawSetBorderColor(self.resource, border_color.resource) @property - @leaky_string def clip_path(self): """(:class:`basestring`) The current clip path. It also can be set. .. versionadded:: 0.4.0 - + .. versionchanged: 0.4.1 + Safely release allocated memory with MagickRelinquishMemory instead of libc.free. """ - return library.DrawGetClipPath(self.resource) + clip_path_p = library.DrawGetClipPath(self.resource) + return text(clip_path_p.value) @clip_path.setter def clip_path(self, path): @@ -291,10 +274,14 @@ def clip_units(self, clip_unit): CLIP_PATH_UNITS.index(clip_unit)) @property - @leaky_string def font(self): - """(:class:`basestring`) The current font name. It also can be set.""" - return library.DrawGetFont(self.resource) + """(:class:`basestring`) The current font name. It also can be set. + + .. versionchanged: 0.4.1 + Safely release allocated memory with MagickRelinquishMemory instead of libc.free. + """ + font_p = library.DrawGetFont(self.resource) + return text(font_p.value) @font.setter def font(self, font): @@ -303,13 +290,15 @@ def font(self, font): library.DrawSetFont(self.resource, binary(font)) @property - @leaky_string def font_family(self): """(:class:`basestring`) The current font family. It also can be set. .. versionadded:: 0.4.0 + .. versionchanged: 0.4.1 + Safely release allocated memory with MagickRelinquishMemory instead of libc.free. """ - return library.DrawGetFontFamily(self.resource) + font_family_p = library.DrawGetFontFamily(self.resource) + return text(font_family_p.value) @font_family.setter def font_family(self, family): @@ -477,7 +466,7 @@ def opacity(self): @opacity.setter def opacity(self, opaque): - library.DrawSetOpacity(self.resource, float(opaque)) + library.DrawSetOpacity(self.resource, ctypes.c_double(opaque)) @property def stroke_antialias(self): @@ -528,6 +517,8 @@ def stroke_dash_array(self): It also can be set. .. versionadded:: 0.4.0 + .. versionchanged: 0.4.1 + Safely release allocated memory with MagickRelinquishMemory instead of libc.free. """ number_elements = ctypes.c_size_t(0) dash_array_p = library.DrawGetStrokeDashArray( @@ -537,7 +528,7 @@ def stroke_dash_array(self): if dash_array_p is not None: dash_array = [float(dash_array_p[i]) for i in xrange(number_elements.value)] - libc.free(dash_array_p) + library.MagickRelinquishMemory(dash_array_p) return dash_array @stroke_dash_array.setter @@ -738,13 +729,15 @@ def text_direction(self, direction): TEXT_DIRECTION_TYPES.index(direction)) @property - @leaky_string def text_encoding(self): """(:class:`basestring`) The internally used text encoding setting. Although it also can be set, but it's not encouraged. + .. versionchanged: 0.4.1 + Safely release allocated memory with MagickRelinquishMemory instead of libc.free. """ - return library.DrawGetTextEncoding(self.resource) + text_encoding_p = library.DrawGetTextEncoding(self.resource) + return text(text_encoding_p.value) @text_encoding.setter def text_encoding(self, encoding): @@ -838,16 +831,11 @@ def vector_graphics(self): to the default state. .. versionadded:: 0.4.0 - + .. versionchanged: 0.4.1 + Safely release allocated memory with MagickRelinquishMemory instead of libc.free. """ vector_graphics_p = library.DrawGetVectorGraphics(self.resource) - vector_graphics = '' - - if vector_graphics_p is not None: - vector_graphics_p = ctypes.cast(vector_graphics_p, ctypes.c_char_p) - vector_graphics = text(vector_graphics_p.value) - libc.free(vector_graphics_p) - return '' + vector_graphics + '' + return '' + text(vector_graphics_p.value) + '' @vector_graphics.setter def vector_graphics(self, vector_graphics): diff --git a/wand/image.py b/wand/image.py index cb5df52a..d07008f7 100644 --- a/wand/image.py +++ b/wand/image.py @@ -30,7 +30,7 @@ 'COMPOSITE_OPERATORS', 'COMPRESSION_TYPES', 'EVALUATE_OPS', 'FILTER_TYPES', 'GRAVITY_TYPES', 'IMAGE_TYPES', 'ORIENTATION_TYPES', 'UNIT_TYPES', - 'BaseImage', 'ChannelDepthDict', 'ChannelImageDict', + 'FUNCTION_TYPES', 'BaseImage', 'ChannelDepthDict', 'ChannelImageDict', 'ClosedImageError', 'HistogramDict', 'Image', 'ImageProperty', 'Iterator', 'Metadata', 'OptionDict', 'manipulative') @@ -415,6 +415,71 @@ 'lzw', 'no', 'piz', 'pxr24', 'rle', 'zip', 'zips' ) +#: (:class:`tuple`) The list of :attr:`Image.function` types. +#: +#: - ``'undefined'`` +#: - ``'polynomial'`` +#: - ``'sinusoid'`` +#: - ``'arcsin'`` +#: - ``'arctan'`` +FUNCTION_TYPES = ('undefined', 'polynomial', 'sinusoid', 'arcsin', 'arctan') + + +#: (:class:`tuple`) The list of :method:`Image.distort` methods. +#: +#: - ``'undefined'`` +#: - ``'affine'`` +#: - ``'affine_projection'`` +#: - ``'scale_rotate_translate'`` +#: - ``'perspective'`` +#: - ``'perspective_projection'`` +#: - ``'bilinear_forward'`` +#: - ``'bilinear_reverse'`` +#: - ``'polynomial'`` +#: - ``'arc'`` +#: - ``'polar'`` +#: - ``'depolar'`` +#: - ``'cylinder_2_plane'`` +#: - ``'plane_2_cylinder'`` +#: - ``'barrel'`` +#: - ``'barrel_inverse'`` +#: - ``'shepards'`` +#: - ``'resize'`` +#: - ``'sentinel'`` +#: +#: .. versionadded:: 0.4.1 +DISTORTION_METHODS = ('undefined', 'affine', 'affine_projection', 'scale_rotate_translate', + 'perspective', 'perspective_projection', 'bilinear_forward', + 'bilinear_reverse', 'polynomial', 'arc', 'polar', 'depolar', + 'cylinder_2_plane', 'plane_2_cylinder', 'barrel', 'barrel_inverse', + 'shepards', 'resize', 'sentinel') + +#: (:class:`tuple`) The list of :attr:`~BaseImage.virtual_pixel` types. +#: - ``'undefined'`` +#: - ``'background'`` +#: - ``'constant'`` +#: - ``'dither'`` +#: - ``'edge'`` +#: - ``'mirror'`` +#: - ``'random'`` +#: - ``'tile'`` +#: - ``'transparent'`` +#: - ``'mask'`` +#: - ``'black'`` +#: - ``'gray'`` +#: - ``'white'`` +#: - ``'horizontal_tile'`` +#: - ``'vertical_tile'`` +#: - ``'horizontal_tile_edge'`` +#: - ``'vertical_tile_edge'`` +#: - ``'checker_tile'`` +#: +#: .. versionadded:: 0.4.1 +VIRTUAL_PIXEL_METHOD = ('undefined', 'background', 'constant', 'dither', + 'edge', 'mirror', 'random', 'tile', 'transparent', + 'mask', 'black', 'gray', 'white', 'horizontal_tile', + 'vertical_tile', 'horizontal_tile_edge', + 'vertical_tile_edge', 'checker_tile') def manipulative(function): """Mark the operation manipulating itself instead of returning new one.""" @@ -858,6 +923,23 @@ def units(self, units): if not r: self.raise_exception() + @property + def virtual_pixel(self): + """(:class:`basestring`) The virtual pixel of image. + This can also be set with a value from :const:`VIRTUAL_PIXEL_METHOD` + ... versionadded:: 0.4.1 + """ + method_index = library.MagickGetImageVirtualPixelMethod(self.wand) + return VIRTUAL_PIXEL_METHOD[method_index] + + @virtual_pixel.setter + def virtual_pixel(self, method): + if method not in VIRTUAL_PIXEL_METHOD: + raise ValueError('expected method from VIRTUAL_PIXEL_METHOD,' + ' not ' + repr(method)) + library.MagickSetImageVirtualPixelMethod(self.wand, + VIRTUAL_PIXEL_METHOD.index(method)) + @property def colorspace(self): """(:class:`basestring`) The image colorspace. @@ -1028,6 +1110,51 @@ def background_color(self, color): if not result: self.raise_exception() + def matte(self, flag): + """Sets the image matte channel. + + :param flag: Matte channel enabled + :type flag: bool + :raises exceptions.TypeError: with invalid argument + + .. versionadded:: 0.4.1 + """ + if flag is True: + flag = 1 + elif flag is False: + flag = 0 + else: + raise TypeError('matte must be bool, not ' + repr(flag)) + library.MagickSetImageMatte(self.wand, flag) + + @property + def matte_color(self): + """(:class:`wand.color.Color`) The color value of the matte channel. + This can also be set. + + ..versionadded:: 0.4.1 + """ + pixel = library.NewPixelWand() + result = library.MagickGetImageMatteColor(self.wand, pixel) + if result: + pixel_size = ctypes.sizeof(MagickPixelPacket) + pixel_buffer = ctypes.create_string_buffer(pixel_size) + library.PixelGetMagickColor(pixel, pixel_buffer) + return Color(raw=pixel_buffer) + self.raise_exception() + + @matte_color.setter + @manipulative + def matte_color(self, color): + if not isinstance(color, Color): + raise TypeError('color must be a wand.color.Color object, not ' + + repr(color)) + with color: + result = library.MagickSetImageMatteColor(self.wand, + color.resource) + if not result: + self.raise_exception() + @property def quantum_range(self): """(:class:`int`) The maxumim value of a color channel that is @@ -1051,6 +1178,34 @@ def histogram(self): """ return HistogramDict(self) + @manipulative + def distort(self, method, arguments, best_fit=False): + """Distorts an image using various distorting methods. + + :param method: Distortion method name from :const:`DISTORTION_METHODS` + :type method: :class:`basestring` + :param arguments: List of distorting float arguments + unique to distortion method + :type arguments: :class:`collections.Sequence` + :param best_fit: Attempt to resize resulting image fit distortion. + Defaults False + :type best_fit: :class:`bool` + + .. versionadded:: 0.4.1 + """ + if method not in DISTORTION_METHODS: + raise ValueError('expected string from DISTORTION_METHODS, not ' + repr(method)) + if not isinstance(arguments, collections.Sequence): + raise TypeError('expected sequence of doubles, not ' + repr(arguments)) + argc = len(arguments) + argv = (ctypes.c_double * argc)(*arguments) + library.MagickDistortImage(self.wand, + DISTORTION_METHODS.index(method), + argc, argv, bool(best_fit)) + self.raise_exception() + + + @manipulative def crop(self, left=0, top=0, right=None, bottom=None, width=None, height=None, reset_coords=True, @@ -1583,6 +1738,119 @@ def flop(self): if not result: self.raise_exception() + @manipulative + def frame(self, matte=None, width=1, height=1, inner_bevel=0, outer_bevel=0): + """Creates a bordered frame around image. Inner & Outer bevel can simulate + a 3D effect. + + :param matte: Color of the frame + :type matte: :class:`wand.color.Color` + :param width: Total size of frame on x-axis + :type width: :class:`numbers.Integral` + :param height: Total size of frame on y-axis + :type height: :class:`numbers.Integral` + :param inner_bevel: Inset shadow length + :type inner_bevel: :class:`numbers.Real` + :param outer_bevel: Outset highlight length + :type outer_bevel: :class:`numbers.Real` + + .. versionadded:: 0.4.1 + """ + if matte is None: + matte = Color('gray') + if not isinstance(matte, Color): + raise TypeError('Expecting instance of Color for matte, not ' + repr(matte)) + if not isinstance(width, numbers.Integral): + raise TypeError('Expecting integer for width, not ' + repr(width)) + if not isinstance(height, numbers.Integral): + raise TypeError('Expecting integer for height, not ' + repr(height)) + if not isinstance(inner_bevel, numbers.Real): + raise TypeError('Expecting real number, not ' + repr(inner_bevel)) + if not isinstance(outer_bevel, numbers.Real): + raise TypeError('Expecting real number, not ' + repr(outer_bevel)) + with matte: + library.MagickFrameImage(self.wand, + matte.resource, + width, height, + inner_bevel, outer_bevel) + + @manipulative + def function(self, function, arguments, channel=None): + """Apply an arithmetic, relational, or logical expression to an image. + + Defaults entire image, but can isolate affects to single color channel + by passing :const:`CHANNELS` value to ``channel`` parameter. + + .. note:: Support for function methods added in the following versions + of ImageMagick. + + - ``'polynomial'`` >= 6.4.8-8 + - ``'sinusoid'`` >= 6.4.8-8 + - ``'arcsin'`` >= 6.5.3-1 + - ``'arctan'`` >= 6.5.3-1 + + :param function: A string listed in :const:`FUNCTION_TYPES` + :type function: :class:`basestring` + :param arguments: A sequence of doubles to apply against ``function`` + :type arguments: :class:`collections.Sequence` + :param channel: Optional :const:`CHANNELS`, defaults all. + :type channel: :class:`basestring` + :raises exception.ValueError: When a ``function``, or ``channel`` is not + defined in there respected constant. + :raises exception.TypeError: If ``arguments`` is not a sequence. + .. versionadded:: 0.4.1 + """ + if function not in FUNCTION_TYPES: + raise ValueError('expected string from FUNCTION_TYPES, not ' + repr(function)) + if not isinstance(arguments, collections.Sequence): + raise TypeError('expecting sequence of arguments, not ' + repr(arguments)) + argc = len(arguments) + argv = (ctypes.c_double * argc)(*arguments) + index = FUNCTION_TYPES.index(function) + if channel is None: + library.MagickFunctionImage(self.wand, index, argc, argv) + elif channel in CHANNELS: + library.MagickFunctionImageChannel(self.wand, CHANNELS[channel], index, argc, argv) + else: + raise ValueError('expected string from CHANNELS, not ' + repr(channel)) + self.raise_exception() + + @manipulative + def fx(self, expression, channel=None): + """Manipulate each pixel of an image by given expression. + + FX will preserver current wand instance, and return a new instance of + :class:`Image` containing affected pixels. + + Defaults entire image, but can isolate affects to single color channel + by passing :const:`CHANNELS` value to ``channel`` parameter. + + .. seealso:: The anatomy of FX expressions can be found at + http://www.imagemagick.org/script/fx.php + + + :param expression: The entire FX expression to apply + :type expression: :class:`basestring` + :param channel: Optional channel to target. + :type channel: :const:`CHANNELS` + :returns: A new instance of an image with expression applied + :rtype: :class:`Image` + + .. versionadded:: 0.4.1 + """ + if not isinstance(expression, string_type): + raise TypeError('expected basestring for expression, not' + repr(expression)) + c_expression = binary(expression) + if channel is None: + new_wand = library.MagickFxImage(self.wand, c_expression) + elif channel in CHANNELS: + new_wand = library.MagickFxImageChannel(self.wand, CHANNELS[channel], c_expression) + else: + raise ValueError('expected string from CHANNELS, not ' + repr(channel)) + if new_wand: + return Image(image=BaseImage(new_wand)) + self.raise_exception() + @manipulative def transparentize(self, transparency): """Makes the image transparent by subtracting some percentage of @@ -2421,6 +2689,77 @@ def trim(self, color=None, fuzz=0): if not result: self.raise_exception() + @manipulative + def transpose(self): + """Creates a vertical mirror image by reflecting the pixels around + the central x-axis while rotating them 90-degrees. + + .. versionadded:: 0.4.1 + """ + result = library.MagickTransposeImage(self.wand) + if not result: + self.raise_exception() + + @manipulative + def transverse(self): + """Creates a horizontal mirror image by reflecting the pixels around + the central y-axis while rotating them 270-degrees. + + .. versionadded:: 0.4.1 + """ + result = library.MagickTransverseImage(self.wand) + if not result: + self.raise_exception() + + @manipulative + def _auto_orient(self): + """Fallback for :attr:`auto_orient()` method (which wraps :c:func:`MagickAutoOrientImage`), + fixes orientation by checking EXIF data. + + .. versionadded:: 0.4.1 + """ + exif_orientation = self.metadata.get('exif:orientation') + if not exif_orientation: + return + + orientation_type = ORIENTATION_TYPES[int(exif_orientation)] + + fn_lookup = { + 'undefined': None, + 'top_left': None, + 'top_right': self.flop, + 'bottom_right': functools.partial(self.rotate, degree=180.0), + 'bottom_left': self.flip, + 'left_top': self.transpose, + 'right_top': functools.partial(self.rotate, degree=90.0), + 'right_bottom': self.transverse, + 'left_bottom': functools.partial(self.rotate, degree=270.0) + } + + fn = fn_lookup.get(orientation_type) + + if not fn: + return + + fn() + self.orientation = 'top_left' + + @manipulative + def auto_orient(self): + """Adjusts an image so that its orientation is suitable + for viewing (i.e. top-left orientation). if available it uses :c:func:`MagickAutoOrientImage` + (was added in ImageMagick 6.8.9+) if you have an older magick library, + it will use :attr:`_auto_orient()` method for fallback + + .. versionadded:: 0.4.1 + """ + try: + result = library.MagickAutoOrientImage(self.wand) + if not result: + self.raise_exception() + except AttributeError as e: + self._auto_orient() + def border(self, color, width, height): """Surrounds the image with a border. diff --git a/wand/version.py b/wand/version.py index a8d5827c..2b77507a 100644 --- a/wand/version.py +++ b/wand/version.py @@ -10,6 +10,17 @@ $ python -m wand.version --verbose Wand 0.0.0 ImageMagick 6.7.7-6 2012-06-03 Q16 http://www.imagemagick.org + $ python -m wand.version --config | grep CC | cut -d : -f 2 + gcc -std=gnu99 -std=gnu99 + $ python -m wand.version --fonts | grep Helvetica + Helvetica + Helvetica-Bold + Helvetica-Light + Helvetica-Narrow + Helvetica-Oblique + $ python -m wand.version --formats | grep CMYK + CMYK + CMYKA .. versionadded:: 0.2.0 The command line interface. @@ -18,6 +29,10 @@ The ``--verbose``/``-v`` option which also prints ImageMagick library version for CLI. +.. versionadded:: 0.4.1 + The ``--fonts``, ``--formats``, & ``--config`` option allows printing + additional information about ImageMagick library. + """ from __future__ import print_function @@ -27,16 +42,16 @@ import sys try: - from .api import libmagick + from .api import libmagick, library except ImportError: libmagick = None -from .compat import text +from .compat import binary, string_type, text __all__ = ('VERSION', 'VERSION_INFO', 'MAGICK_VERSION', 'MAGICK_VERSION_INFO', 'MAGICK_VERSION_NUMBER', 'MAGICK_RELEASE_DATE', 'MAGICK_RELEASE_DATE_STRING', - 'QUANTUM_DEPTH') + 'QUANTUM_DEPTH', 'configure_options', 'fonts', 'formats') #: (:class:`tuple`) The version tuple e.g. ``(0, 1, 2)``. #: @@ -104,6 +119,110 @@ del c_magick_version, _match, c_quantum_depth + +def configure_options(pattern='*'): + """ + Queries ImageMagick library for configurations options given at + compile-time. + + Example: Find where the ImageMagick documents are installed:: + + >>> from wand.version import configure_options + >>> configure_options('DOC*') + {'DOCUMENTATION_PATH': '/usr/local/share/doc/ImageMagick-6'} + + :param pattern: A term to filter queries against. Supports wildcard '*' + characters. Default patterns '*' for all options. + :type pattern: :class:`basestring` + :returns: Directory of configuration options matching given pattern + :rtype: :class:`collections.defaultdict` + """ + if not isinstance(pattern, string_type): + raise TypeError('pattern must be a string, not ' + repr(pattern)) + pattern_p = ctypes.create_string_buffer(binary(pattern)) + config_count = ctypes.c_size_t(0) + configs = {} + configs_p = library.MagickQueryConfigureOptions(pattern_p, + ctypes.byref(config_count)) + cursor = 0 + while cursor < config_count.value: + config = configs_p[cursor].value + value = library.MagickQueryConfigureOption(config) + configs[text(config)] = text(value.value) + cursor += 1 + return configs + + +def fonts(pattern='*'): + """ + Queries ImageMagick library for available fonts. + + Available fonts can be configured by defining `types.xml`, + `type-ghostscript.xml`, or `type-windows.xml`. + Use :func:`wand.version.configure_options` to locate system search path, + and `resources `_ + article for defining xml file. + + Example: List all bold Helvetica fonts:: + + >>> from wand.version import fonts + >>> fonts('*Helvetica*Bold*') + ['Helvetica-Bold', 'Helvetica-Bold-Oblique', 'Helvetica-BoldOblique', + 'Helvetica-Narrow-Bold', 'Helvetica-Narrow-BoldOblique'] + + + :param pattern: A term to filter queries against. Supports wildcard '*' + characters. Default patterns '*' for all options. + :type pattern: :class:`basestring` + :returns: Sequence of matching fonts + :rtype: :class:`collections.Sequence` + """ + if not isinstance(pattern, string_type): + raise TypeError('pattern must be a string, not ' + repr(pattern)) + pattern_p = ctypes.create_string_buffer(binary(pattern)) + number_fonts = ctypes.c_size_t(0) + fonts = [] + fonts_p = library.MagickQueryFonts(pattern_p, + ctypes.byref(number_fonts)) + cursor = 0 + while cursor < number_fonts.value: + font = fonts_p[cursor].value + fonts.append(text(font)) + cursor += 1 + return fonts + + +def formats(pattern='*'): + """ + Queries ImageMagick library for supported formats. + + Example: List supported PNG formats:: + + >>> from wand.version import formats + >>> formats('PNG*') + ['PNG', 'PNG00', 'PNG8', 'PNG24', 'PNG32', 'PNG48', 'PNG64'] + + + :param pattern: A term to filter formats against. Supports wildcards '*' + characters. Default pattern '*' for all formats. + :type pattern: :class:`basestring` + :returns: Sequence of matching formats + :rtype: :class:`collections.Sequence` + """ + if not isinstance(pattern, string_type): + raise TypeError('pattern must be a string, not ' + repr(pattern)) + pattern_p = ctypes.create_string_buffer(binary(pattern)) + number_formats = ctypes.c_size_t(0) + formats = [] + formats_p = library.MagickQueryFormats(pattern_p, + ctypes.byref(number_formats)) + cursor = 0 + while cursor < number_formats.value: + value = formats_p[cursor].value + formats.append(text(value)) + cursor += 1 + return formats + if __doc__ is not None: __doc__ = __doc__.replace('0.0.0', VERSION) @@ -118,5 +237,15 @@ print(MAGICK_VERSION) except NameError: pass + elif '--fonts' in options: + for font in fonts(): + print(font) + elif '--formats' in options: + for supported_format in formats(): + print(supported_format) + elif '--config' in options: + config_options = configure_options() + for key in config_options: + print('{:24s}: {}'.format(key, config_options[key])) else: print(VERSION)