// Image+Trim.swift // // Copyright © 2020 Christopher Zielinski. // https://gist.github.com/chriszielinski/aec9a2f2ba54745dc715dd55f5718177 // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. #if canImport(UIKit) import UIKit #else import AppKit #endif #if canImport(UIKit) typealias Image = UIImage #else typealias Image = NSImage #endif extension Image { /// Crops the insets of transparency around the image. /// /// - Parameters: /// - maximumAlphaChannel: The maximum alpha channel value to consider _transparent_ and thus crop. Any alpha value /// strictly greater than `maximumAlphaChannel` will be considered opaque. func trimmingTransparentPixels(maximumAlphaChannel: UInt8 = 0) -> Image? { guard size.height > 1 && size.width > 1 else { return self } #if canImport(UIKit) guard let cgImage = cgImage?.trimmingTransparentPixels(maximumAlphaChannel: maximumAlphaChannel) else { return nil } return UIImage(cgImage: cgImage, scale: scale, orientation: imageOrientation) #else guard let cgImage = cgImage(forProposedRect: nil, context: nil, hints: nil)? .trimmingTransparentPixels(maximumAlphaChannel: maximumAlphaChannel) else { return nil } let scale = recommendedLayerContentsScale(0) let scaledSize = CGSize(width: CGFloat(cgImage.width) / scale, height: CGFloat(cgImage.height) / scale) let image = NSImage(cgImage: cgImage, size: scaledSize) image.isTemplate = isTemplate return image #endif } } extension CGImage { /// Crops the insets of transparency around the image. /// /// - Parameters: /// - maximumAlphaChannel: The maximum alpha channel value to consider _transparent_ and thus crop. Any alpha value /// strictly greater than `maximumAlphaChannel` will be considered opaque. func trimmingTransparentPixels(maximumAlphaChannel: UInt8 = 0) -> CGImage? { return _CGImageTransparencyTrimmer(image: self, maximumAlphaChannel: maximumAlphaChannel)?.trim() } } private struct _CGImageTransparencyTrimmer { let image: CGImage let maximumAlphaChannel: UInt8 let cgContext: CGContext let zeroByteBlock: UnsafeMutableRawPointer let pixelRowRange: Range let pixelColumnRange: Range init?(image: CGImage, maximumAlphaChannel: UInt8) { guard let cgContext = CGContext(data: nil, width: image.width, height: image.height, bitsPerComponent: 8, bytesPerRow: 0, space: CGColorSpaceCreateDeviceGray(), bitmapInfo: CGImageAlphaInfo.alphaOnly.rawValue), cgContext.data != nil else { return nil } cgContext.draw(image, in: CGRect(origin: .zero, size: CGSize(width: image.width, height: image.height))) guard let zeroByteBlock = calloc(image.width, MemoryLayout.size) else { return nil } self.image = image self.maximumAlphaChannel = maximumAlphaChannel self.cgContext = cgContext self.zeroByteBlock = zeroByteBlock pixelRowRange = 0.. CGImage? { guard let topInset = firstOpaquePixelRow(in: pixelRowRange), let bottomOpaqueRow = firstOpaquePixelRow(in: pixelRowRange.reversed()), let leftInset = firstOpaquePixelColumn(in: pixelColumnRange), let rightOpaqueColumn = firstOpaquePixelColumn(in: pixelColumnRange.reversed()) else { return nil } let bottomInset = (image.height - 1) - bottomOpaqueRow let rightInset = (image.width - 1) - rightOpaqueColumn guard !(topInset == 0 && bottomInset == 0 && leftInset == 0 && rightInset == 0) else { return image } return image.cropping(to: CGRect(origin: CGPoint(x: leftInset, y: topInset), size: CGSize(width: image.width - (leftInset + rightInset), height: image.height - (topInset + bottomInset)))) } @inlinable func isPixelOpaque(column: Int, row: Int) -> Bool { // Sanity check: It is safe to get the data pointer in iOS 4.0+ and macOS 10.6+ only. assert(cgContext.data != nil) return cgContext.data!.load(fromByteOffset: (row * cgContext.bytesPerRow) + column, as: UInt8.self) > maximumAlphaChannel } @inlinable func isPixelRowTransparent(_ row: Int) -> Bool { assert(cgContext.data != nil) // `memcmp` will efficiently check if the entire pixel row has zero alpha values return memcmp(cgContext.data! + (row * cgContext.bytesPerRow), zeroByteBlock, image.width) == 0 // When the entire row is NOT zeroed, we proceed to check each pixel's alpha // value individually until we locate the first "opaque" pixel (very ~not~ efficient). || !pixelColumnRange.contains(where: { isPixelOpaque(column: $0, row: row) }) } @inlinable func firstOpaquePixelRow(in rowRange: T) -> Int? where T.Element == Int { return rowRange.first(where: { !isPixelRowTransparent($0) }) } @inlinable func firstOpaquePixelColumn(in columnRange: T) -> Int? where T.Element == Int { return columnRange.first(where: { column in pixelRowRange.contains(where: { isPixelOpaque(column: column, row: $0) }) }) } }