CGRotation

A PyObjC Example without documentation

Sources

CGImageUtils.py

import math

import Cocoa
import LaunchServices
import objc
import Quartz


class ImageInfo:
    __slots__ = (
        "fRotation",
        "fScaleX",
        "fScaleY",
        "fTranslateX",
        "fTranslateY",
        "fImageRef",
        "fProperties",
        "fOrientation",
    )

    def __init__(self):
        self.fRotation = 0.0  # The rotation about the center of the image (degrees)
        self.fScaleX = 0.0  # The scaling of the image along it's X-axis
        self.fScaleY = 0.0  # The scaling of the image along it's Y-axis
        self.fTranslateX = 0.0  # Move the image along the X-axis
        self.fTranslateY = 0.0  # Move the image along the Y-axis
        self.fImageRef = None  # The image itself
        self.fProperties = None  # Image properties
        self.fOrientation = (
            None  # Affine transform that ensures the image displays correctly
        )


# Create a new image from a file at the given url
# Returns None if unsuccessful.
def IICreateImage(url):
    ii = None
    # Try to create an image source to the image passed to us
    imageSrc = Quartz.CGImageSourceCreateWithURL(url, None)
    if imageSrc is not None:
        # And if we can, try to obtain the first image available
        image = Quartz.CGImageSourceCreateImageAtIndex(imageSrc, 0, None)
        if image is not None:
            # and if we could, create the ImageInfo struct with default values
            ii = ImageInfo()
            ii.fRotation = 0.0
            ii.fScaleX = 1.0
            ii.fScaleY = 1.0
            ii.fTranslateX = 0.0
            ii.fTranslateY = 0.0
            # the ImageInfo struct owns this CGImageRef now, so no need for a retain.
            ii.fImageRef = image
            # the ImageInfo struct owns this CFDictionaryRef, so no need for a retain.
            ii.fProperties = Quartz.CGImageSourceCopyPropertiesAtIndex(
                imageSrc, 0, None
            )
            # Setup the orientation transformation matrix so that the image will
            # display with the proper orientation
            IIGetOrientationTransform(ii)

    return ii


# Transforms the context based on the orientation of the image.
# This ensures the image always appears correctly when drawn.
def IIGetOrientationTransform(image):
    w = Quartz.CGImageGetWidth(image.fImageRef)
    h = Quartz.CGImageGetHeight(image.fImageRef)
    if image.fProperties is not None:
        # The Orientations listed here are mirrored from CGImageProperties.h,
        # listed under the kCGImagePropertyOrientation key.
        orientation = IIGetImageOrientation(image)
        if orientation == 1:
            # 1 = 0th row is at the top, and 0th column is on the left.
            # Orientation Normal
            image.fOrientation = Quartz.CGAffineTransformMake(
                1.0, 0.0, 0.0, 1.0, 0.0, 0.0
            )

        elif orientation == 2:
            # 2 = 0th row is at the top, and 0th column is on the right.
            # Flip Horizontal
            image.fOrientation = Quartz.CGAffineTransformMake(
                -1.0, 0.0, 0.0, 1.0, w, 0.0
            )

        elif orientation == 3:
            # 3 = 0th row is at the bottom, and 0th column is on the right.
            # Rotate 180 degrees
            image.fOrientation = Quartz.CGAffineTransformMake(
                -1.0, 0.0, 0.0, -1.0, w, h
            )

        elif orientation == 4:
            # 4 = 0th row is at the bottom, and 0th column is on the left.
            # Flip Vertical
            image.fOrientation = Quartz.CGAffineTransformMake(1.0, 0.0, 0, -1.0, 0.0, h)

        elif orientation == 5:
            # 5 = 0th row is on the left, and 0th column is the top.
            # Rotate -90 degrees and Flip Vertical
            image.fOrientation = Quartz.CGAffineTransformMake(
                0.0, -1.0, -1.0, 0.0, h, w
            )

        elif orientation == 6:
            # 6 = 0th row is on the right, and 0th column is the top.
            # Rotate 90 degrees
            image.fOrientation = Quartz.CGAffineTransformMake(
                0.0, -1.0, 1.0, 0.0, 0.0, w
            )

        elif orientation == 7:
            # 7 = 0th row is on the right, and 0th column is the bottom.
            # Rotate 90 degrees and Flip Vertical
            image.fOrientation = Quartz.CGAffineTransformMake(
                0.0, 1.0, 1.0, 0.0, 0.0, 0.0
            )

        elif orientation == 8:
            # 8 = 0th row is on the left, and 0th column is the bottom.
            # Rotate -90 degrees
            image.fOrientation = Quartz.CGAffineTransformMake(
                0.0, 1.0, -1.0, 0.0, h, 0.0
            )


# Gets the orientation of the image from the properties dictionary if available
# If the kCGImagePropertyOrientation is not available or invalid,
# then 1, the default orientation, is returned.
def IIGetImageOrientation(image):
    result = 1
    if image.fProperties is not None:
        orientation = image.fProperties.get(Quartz.kCGImagePropertyOrientation)
        if orientation is not None:
            result = orientation

    return result


# Save the given image to a file at the given url.
# Returns true if successful, false otherwise.
def IISaveImage(image, url, width, height):
    result = False

    # If there is no image, no destination, or the width/height is 0, then fail early.
    assert (
        (image is not None) and (url is not None) and (width != 0.0) and (height != 0.0)
    )

    # Try to create a jpeg image destination at the url given to us
    imageDest = Quartz.CGImageDestinationCreateWithURL(
        url, LaunchServices.kUTTypeJPEG, 1, None
    )
    if imageDest is not None:
        # And if we can, then we can start building our final image.
        # We begin by creating a CGBitmapContext to host our destination image.

        # Allocate enough space to hold our pixels
        imageData = objc.allocateBuffer(int(4 * width * height))

        # Create the bitmap context
        bitmapContext = Quartz.CGBitmapContextCreate(
            imageData,  # image data we just allocated...
            width,  # width
            height,  # height
            8,  # 8 bits per component
            4 * width,  # bytes per pixel times number of pixels wide
            Quartz.CGImageGetColorSpace(
                image.fImageRef
            ),  # use the same colorspace as the original image
            Quartz.kCGImageAlphaPremultipliedFirst,
        )  # use premultiplied alpha

        # Check that all that went well
        if bitmapContext is not None:
            # Now, we draw the image to the bitmap context
            IIDrawImageTransformed(
                image, bitmapContext, Quartz.CGRectMake(0.0, 0.0, width, height)
            )

            # We have now gotten our image data to the bitmap context, and correspondingly
            # into imageData. If we wanted to, we could look at any of the pixels of the image
            # and manipulate them in any way that we desire, but for this case, we're just
            # going to ask ImageIO to write this out to disk.

            # Obtain a CGImageRef from the bitmap context for ImageIO
            imageIOImage = Quartz.CGBitmapContextCreateImage(bitmapContext)

            # Check if we have additional properties from the original image
            if image.fProperties is not None:
                # If we do, then we want to inspect the orientation property.
                # If it exists and is not the default orientation, then we
                # want to replace that orientation in the destination file
                orientation = IIGetImageOrientation(image)
                if orientation != 1:
                    # If the orientation in the original image was not the default,
                    # then we need to replace that key in a duplicate of that dictionary
                    # and then pass that dictionary to ImageIO when adding the image.
                    prop = Cocoa.CFDictionaryCreateMutableCopy(
                        None, 0, image.fProperties
                    )
                    orientation = 1
                    prop[Quartz.kCGImagePropertyOrientation] = orientation

                    # And add the image with the new properties
                    Quartz.CGImageDestinationAddImage(imageDest, imageIOImage, prop)

                else:
                    # Otherwise, the image was already in the default orientation and we can
                    # just save it with the original properties.
                    Quartz.CGImageDestinationAddImage(
                        imageDest, imageIOImage, image.fProperties
                    )

            else:
                # If we don't, then just add the image without properties
                Quartz.CGImageDestinationAddImage(imageDest, imageIOImage, None)

            del bitmapContext

        # Finalize the image destination
        result = Quartz.CGImageDestinationFinalize(imageDest)
        del imageDest

    return result


# Applies the transformations specified in the ImageInfo struct without drawing the actual image
def IIApplyTransformation(image, context, bounds):
    if image is not None:
        # Whenever you do multiple CTM changes, you have to be very careful with
        # order.  Changing the order of your CTM changes changes the outcome of
        # the drawing operation. For example, if you scale a context by 2.0 along
        # the x-axis, and then translate the context by 10.0 along the x-axis,
        # then you will see your drawing will be in a different position than if
        # you had done the operations in the opposite order.
        #
        # Our intent with this operation is that we want to change the location
        # from which we start drawing (translation), then rotate our axies so
        # that our image appears at an angle (rotation), and finally
        # scale our axies so that our image has a different size (scale).
        # Changing the order of operations will markedly change the results.
        IITranslateContext(image, context)
        IIRotateContext(image, context, bounds)
        IIScaleContext(image, context, bounds)


# Draw the image to the given context centered inside the given bounds
def IIDrawImage(image, context, bounds):
    imageRect = Cocoa.NSRect()
    if image is not None and context is not None:
        # Setup the image rect so that the image fills it's natural boundaries
        # in the base coordinate system.
        imageRect.origin.x = 0.0
        imageRect.origin.y = 0.0
        imageRect.size.width = Quartz.CGImageGetWidth(image.fImageRef)
        imageRect.size.height = Quartz.CGImageGetHeight(image.fImageRef)

        # Obtain the orientation matrix for this image
        ctm = image.fOrientation

        # Before we can apply the orientation matrix, we need to translate the
        # coordinate system so the center of the rectangle matces the center of
        # the image.
        if image.fProperties is None or IIGetImageOrientation(image) < 5:
            # For orientations 1-4, the images are unrotated, so the width and
            # height of the base image can be used as the width and height of
            # the coordinate translation calculation.
            Quartz.CGContextTranslateCTM(
                context,
                math.floor((bounds.size.width - imageRect.size.width) / 2.0),
                math.floor((bounds.size.height - imageRect.size.height) / 2.0),
            )

        else:
            # For orientations 5-8, the images are rotated 90 or -90 degrees,
            # so we need to use the image width in place of the height and
            # vice versa.
            Quartz.CGContextTranslateCTM(
                context,
                math.floor((bounds.size.width - imageRect.size.height) / 2.0),
                math.floor((bounds.size.height - imageRect.size.width) / 2.0),
            )

        # Finally, orient the context so that the image draws naturally.
        Quartz.CGContextConcatCTM(context, ctm)

        # And draw the image.
        Quartz.CGContextDrawImage(context, imageRect, image.fImageRef)


# Rotates the context around the center point of the given bounds
def IIRotateContext(image, context, bounds):
    # First we translate the context such that the 0,0 location is at the center of the bounds
    Quartz.CGContextTranslateCTM(
        context, bounds.size.width / 2.0, bounds.size.height / 2.0
    )

    # Then we rotate the context, converting our angle from degrees to radians
    Quartz.CGContextRotateCTM(context, image.fRotation * math.pi / 180.0)

    # Finally we have to restore the center position
    Quartz.CGContextTranslateCTM(
        context, -bounds.size.width / 2.0, -bounds.size.height / 2.0
    )


# Scale the context around the center point of the given bounds
def IIScaleContext(image, context, bounds):
    # First we translate the context such that the 0,0 location is at the center of the bounds
    Quartz.CGContextTranslateCTM(
        context, bounds.size.width / 2.0, bounds.size.height / 2.0
    )

    # Next we scale the context to the size that we want
    Quartz.CGContextScaleCTM(context, image.fScaleX, image.fScaleY)

    # Finally we have to restore the center position
    Quartz.CGContextTranslateCTM(
        context, -bounds.size.width / 2.0, -bounds.size.height / 2.0
    )


# Translate the context
def IITranslateContext(image, context):
    # Translation is easy, just translate.
    Quartz.CGContextTranslateCTM(context, image.fTranslateX, image.fTranslateY)


# Draw the image to the given context centered inside the given bounds with
# the transformation info. The CTM of the context is unchanged after this call
def IIDrawImageTransformed(image, context, bounds):
    # We save the current graphics state so as to not disrupt it for the caller.
    Quartz.CGContextSaveGState(context)

    # Apply the transformation
    IIApplyTransformation(image, context, bounds)

    # Draw the image centered in the context
    IIDrawImage(image, context, bounds)

    # Restore our original graphics state.
    Quartz.CGContextRestoreGState(context)


# Release the ImageInfo struct and other associated data
# you should not refer to the reference after this call
# This function is None safe.
def IIRelease(image):
    pass

CGImageView.py

import CGImageUtils
import Cocoa
import objc
import Quartz


class CGImageView(Cocoa.NSView):
    _image = objc.ivar()

    def setImage_(self, img):
        if img is not None and self._image is not img:
            self._image = img
            # Mark this view as needing to be redisplayed.
            self.setNeedsDisplay_(True)

    def image(self):
        return self._image

    def drawRect_(self, rect):
        # Obtain the current context
        ctx = Cocoa.NSGraphicsContext.currentContext().graphicsPort()

        # Draw the image in the context
        CGImageUtils.IIDrawImageTransformed(
            self._image,
            ctx,
            Quartz.CGRectMake(
                rect.origin.x, rect.origin.y, rect.size.width, rect.size.height
            ),
        )

        # Draw the view border, just a simple stroked rectangle
        Quartz.CGContextAddRect(
            ctx,
            Quartz.CGRectMake(
                rect.origin.x, rect.origin.y, rect.size.width, rect.size.height
            ),
        )
        Quartz.CGContextSetRGBStrokeColor(ctx, 1.0, 0.0, 0.0, 1.0)
        Quartz.CGContextStrokePath(ctx)

Controller.py

import math

import CGImageUtils
import Cocoa
import LaunchServices
import objc
import Quartz


class Controller(Cocoa.NSObject):
    imageView = objc.IBOutlet()
    scaleYView = objc.IBOutlet()
    textScaleYView = objc.IBOutlet()

    _rotation = objc.ivar.float()
    _scaleX = objc.ivar.float()
    _scaleY = objc.ivar.float()
    _translateX = objc.ivar.float()
    _translateY = objc.ivar.float()
    _preserveAspectRatio = objc.ivar.bool()

    openImageIOSupportedTypes = objc.ivar()

    def awakeFromNib(self):
        self.openImageIOSupportedTypes = None
        # Ask CFBundle for the location of our demo image
        url = Cocoa.CFBundleCopyResourceURL(
            Cocoa.CFBundleGetMainBundle(), "demo", "png", None
        )
        if url is not None:
            # And if available, load it
            self.imageView.setImage_(CGImageUtils.IICreateImage(url))

        self.imageView.window().center()
        self.setRotation_(0.0)
        self.setScaleX_(1.0)
        self.setScaleY_(1.0)
        self.setTranslateX_(0.0)
        self.setTranslateY_(0.0)
        self.setPreserveAspectRatio_(False)

    @objc.IBAction
    def changeScaleX_(self, sender):
        self.setScaleX_(self._scaleX + sender.floatValue())
        sender.setFloatValue_(0.0)

    @objc.IBAction
    def changeScaleY_(self, sender):
        self.setScaleY_(self._scaleY + sender.floatValue())
        sender.setFloatValue_(0.0)

    @objc.IBAction
    def changeTranslateX_(self, sender):
        self.setTranslateX_(self._translateX + sender.floatValue())
        sender.setFloatValue_(0.0)

    @objc.IBAction
    def changeTranslateY_(self, sender):
        self.setTranslateY_(self._translateY + sender.floatValue())
        sender.setFloatValue_(0.0)

    @objc.IBAction
    def reset_(self, sender):
        self.setRotation_(0.0)
        self.setScaleX_(1.0)
        self.setScaleY_(1.0)
        self.setTranslateX_(0.0)
        self.setTranslateY_(0.0)

        self.imageView.setNeedsDisplay_(True)

    def extensionsForUTI_(self, uti):
        """
        Returns an array with the extensions that match the given
        Uniform Type Identifier (UTI).
        """
        # If anything goes wrong, we'll return None, otherwise this will be the array
        # of extensions for this image type.
        extensions = None
        # Only get extensions for UTIs that are images (i.e. conforms to
        # public.image aka kUTTypeImage) This excludes PDF support that ImageIO
        # advertises, but won't actually use.
        if LaunchServices.UTTypeConformsTo(uti, LaunchServices.kUTTypeImage):
            # Copy the declaration for the UTI (if it exists)
            declaration = LaunchServices.UTTypeCopyDeclaration(uti)
            if declaration is not None:
                # Grab the tags for this UTI, which includes extensions, OSTypes and MIME types.
                tags = Cocoa.CFDictionaryGetValue(
                    declaration, LaunchServices.kUTTypeTagSpecificationKey
                )
                if tags is not None:
                    # We are interested specifically in the extensions that this UTI uses
                    filenameExtensions = tags.get(
                        LaunchServices.kUTTagClassFilenameExtension
                    )
                    if filenameExtensions is not None:
                        # It is valid for a UTI to export either an Array
                        # (of Strings) representing multiple tags, or a String
                        # representing a single tag.
                        type_id = Cocoa.CFGetTypeID(filenameExtensions)
                        if type_id == Cocoa.CFStringGetTypeID():
                            # If a string was exported, then wrap it up in an array.
                            extensions = Cocoa.NSArray.arrayWithObject_(
                                filenameExtensions
                            )
                        elif type_id == Cocoa.CFArrayGetTypeID():
                            # If an array was exported, then just return that array.
                            extensions = filenameExtensions.copy()

        return extensions

    # On Tiger NSOpenPanel only understands extensions, not UTIs, so we have to
    # obtain a list of extensions from the UTIs that Image IO tells us it can
    # handle.
    def createOpenTypesArray(self):
        if self.openImageIOSupportedTypes is None:
            imageIOUTIs = Quartz.CGImageSourceCopyTypeIdentifiers()
            count = len(imageIOUTIs)
            self.openImageIOSupportedTypes = (
                Cocoa.NSMutableArray.alloc().initWithCapacity_(count)
            )
            for i in range(count):
                self.openImageIOSupportedTypes.addObjectsFromArray_(
                    self.extensionsForUTI_(imageIOUTIs[i])
                )

    @objc.IBAction
    def openDocument_(self, sender):
        panel = Cocoa.NSOpenPanel.openPanel()
        panel.setAllowsMultipleSelection_(False)
        panel.setResolvesAliases_(True)
        panel.setTreatsFilePackagesAsDirectories_(True)

        self.createOpenTypesArray()

        panel.beginSheetForDirectory_file_types_modalForWindow_modalDelegate_didEndSelector_contextInfo_(  # noqa: B950
            None,
            None,
            self.openImageIOSupportedTypes,
            self.imageView.window(),
            self,
            "openImageDidEnd:returnCode:contextInfo:",
            None,
        )

    @objc.signature(b"v@:@i^v")
    def openImageDidEnd_returnCode_contextInfo_(self, panel, returnCode, contextInfo_):
        if returnCode == Cocoa.NSOKButton:
            if len(panel.filenames()) > 0:
                image = CGImageUtils.IICreateImage(
                    Cocoa.NSURL.fileURLWithPath_(panel.filenames()[0])
                )
                if image is not None:
                    # Ownership is transferred to the CGImageView.
                    self.imageView.setImage_(image)

    @objc.IBAction
    def saveDocumentAs_(self, sender):
        panel = Cocoa.NSSavePanel.savePanel()
        panel.setCanSelectHiddenExtension_(True)
        panel.setRequiredFileType_("jpeg")
        panel.setAllowsOtherFileTypes_(False)
        panel.setTreatsFilePackagesAsDirectories_(True)

        panel.beginSheetForDirectory_file_modalForWindow_modalDelegate_didEndSelector_contextInfo_(  # noqa: B950
            None,
            "untitled image",
            self.imageView.window(),
            self,
            "saveImageDidEnd:returnCode:contextInfo:",
            None,
        )

    @objc.signature(b"v@:@i^v")
    def saveImageDidEnd_returnCode_contextInfo_(self, panel, returnCode, contextInfo):
        if returnCode == Cocoa.NSOKButton:
            frame = self.imageView.frame()
            CGImageUtils.IISaveImage(
                self.imageView.image(),
                panel.URL(),
                math.ceil(frame.size.width),
                math.ceil(frame.size.height),
            )

    def setRotation_(self, r):
        r = r % 360.0
        if r < 0:
            r += 360.0

        self._rotation = r
        self.imageView.image().fRotation = 360.0 - r  # XXX
        self.imageView.setNeedsDisplay_(True)

    def setScaleX_(self, x):
        self._scaleX = x
        self.imageView.image().fScaleX = self._scaleX
        if self._preserveAspectRatio:
            self.imageView.image().fScaleY = self._scaleX

        self.imageView.setNeedsDisplay_(True)

    def setScaleY_(self, y):
        self._scaleY = y
        if not self._preserveAspectRatio:
            self.imageView.image().fScaleY = self._scaleY
            self.imageView.setNeedsDisplay_(True)

    def setPreserveAspectRatio_(self, preserve):
        self._preserveAspectRatio = preserve
        self.imageView.image().fScaleX = self._scaleX
        if self._preserveAspectRatio:
            self.imageView.image().fScaleY = self._scaleX

        else:
            self.imageView.image().fScaleY = self._scaleY

        self.scaleYView.setEnabled_(not self._preserveAspectRatio)
        self.textScaleYView.setEnabled_(not self._preserveAspectRatio)
        self.imageView.setNeedsDisplay_(True)

    def setTranslateX_(self, x):
        self._translateX = x
        self.imageView.image().fTranslateX = self._translateX
        self.imageView.setNeedsDisplay_(True)

    def setTranslateY_(self, y):
        self._translateY = y
        self.imageView.image().fTranslateY = self._translateY
        self.imageView.setNeedsDisplay_(True)

    def rotation(self):
        return self._rotation

    def scaleX(self):
        return self._scaleX

    def scaleY(self):
        return self._scaleY

    def preserveAspectRatio(self):
        return self._preserveAspectRatio

    def translateX(self):
        return self._translateX

    def translateY(self):
        return self._translateY

main.py

import CGImageUtils  # noqa: F401
import CGImageView  # noqa: F401
import Controller  # noqa: F401
from PyObjCTools import AppHelper


AppHelper.runEventLoop()

setup.py

"""
Script for building the example.

Usage:
    python3 setup.py py2app
"""

from setuptools import setup

setup(
    name="CGRotation",
    app=["main.py"],
    data_files=["English.lproj", "demo.png"],
    setup_requires=["py2app", "pyobjc-framework-Cocoa", "pyobjc-framework-Quartz"],
)