// // Copyright (c) 2019 Open Whisper Systems. All rights reserved. // import UIKit @objc public enum ImageEditorError: Int, Error { case assertionError case invalidInput } @objc public enum ImageEditorItemType: Int { case test case stroke } // MARK: - // Instances of ImageEditorItem should be treated // as immutable, once configured. @objc public class ImageEditorItem: NSObject { @objc public let itemId: String @objc public let itemType: ImageEditorItemType @objc public init(itemType: ImageEditorItemType) { self.itemId = UUID().uuidString self.itemType = itemType super.init() } @objc public init(itemId: String, itemType: ImageEditorItemType) { self.itemId = itemId self.itemType = itemType super.init() } public typealias PointConversionFunction = (CGPoint) -> CGPoint public func clone(withPointConversionFunction conversion: PointConversionFunction) -> ImageEditorItem { return ImageEditorItem(itemId: itemId, itemType: itemType) } } // MARK: - @objc public class ImageEditorStrokeItem: ImageEditorItem { // Until we need to serialize these items, // just use UIColor. @objc public let color: UIColor // Represented in a "ULO unit" coordinate system // for source image. // // "ULO" coordinate system is "upper-left-origin". // // "Unit" coordinate system means values are expressed // in terms of some other values, in this case the // width and height of the source image. // // * 0.0 = left edge // * 1.0 = right edge // * 0.0 = top edge // * 1.0 = bottom edge public typealias StrokeSample = CGPoint @objc public let unitSamples: [StrokeSample] // Expressed as a "Unit" value as a fraction of // min(width, height) of the destination viewport. @objc public let unitStrokeWidth: CGFloat @objc public init(color: UIColor, unitSamples: [StrokeSample], unitStrokeWidth: CGFloat) { self.color = color self.unitSamples = unitSamples self.unitStrokeWidth = unitStrokeWidth super.init(itemType: .stroke) } @objc public init(itemId: String, color: UIColor, unitSamples: [StrokeSample], unitStrokeWidth: CGFloat) { self.color = color self.unitSamples = unitSamples self.unitStrokeWidth = unitStrokeWidth super.init(itemId: itemId, itemType: .stroke) } @objc public class func defaultUnitStrokeWidth() -> CGFloat { return 0.02 } @objc public class func strokeWidth(forUnitStrokeWidth unitStrokeWidth: CGFloat, dstSize: CGSize) -> CGFloat { return CGFloatClamp01(unitStrokeWidth) * min(dstSize.width, dstSize.height) } public override func clone(withPointConversionFunction conversion: PointConversionFunction) -> ImageEditorItem { // TODO: We might want to convert the unitStrokeWidth too. let convertedUnitSamples = unitSamples.map { (sample) in conversion(sample) } return ImageEditorStrokeItem(itemId: itemId, color: color, unitSamples: convertedUnitSamples, unitStrokeWidth: unitStrokeWidth) } } // MARK: - public class OrderedDictionary: NSObject { public typealias KeyType = String var keyValueMap = [KeyType: ValueType]() var orderedKeys = [KeyType]() public override init() { } // Used to clone copies of instances of this class. public init(keyValueMap: [KeyType: ValueType], orderedKeys: [KeyType]) { self.keyValueMap = keyValueMap self.orderedKeys = orderedKeys } // Since the contents are immutable, we only modify copies // made with this method. public func clone() -> OrderedDictionary { return OrderedDictionary(keyValueMap: keyValueMap, orderedKeys: orderedKeys) } public func value(forKey key: KeyType) -> ValueType? { return keyValueMap[key] } public func append(key: KeyType, value: ValueType) { if keyValueMap[key] != nil { owsFailDebug("Unexpected duplicate key in key map: \(key)") } keyValueMap[key] = value if orderedKeys.contains(key) { owsFailDebug("Unexpected duplicate key in key list: \(key)") } else { orderedKeys.append(key) } if orderedKeys.count != keyValueMap.count { owsFailDebug("Invalid contents.") } } public func replace(key: KeyType, value: ValueType) { if keyValueMap[key] == nil { owsFailDebug("Missing key in key map: \(key)") } keyValueMap[key] = value if !orderedKeys.contains(key) { owsFailDebug("Missing key in key list: \(key)") } if orderedKeys.count != keyValueMap.count { owsFailDebug("Invalid contents.") } } public func remove(key: KeyType) { if keyValueMap[key] == nil { owsFailDebug("Missing key in key map: \(key)") } else { keyValueMap.removeValue(forKey: key) } if !orderedKeys.contains(key) { owsFailDebug("Missing key in key list: \(key)") } else { orderedKeys = orderedKeys.filter { $0 != key } } if orderedKeys.count != keyValueMap.count { owsFailDebug("Invalid contents.") } } public var count: Int { if orderedKeys.count != keyValueMap.count { owsFailDebug("Invalid contents.") } return orderedKeys.count } public func orderedValues() -> [ValueType] { var values = [ValueType]() for key in orderedKeys { guard let value = self.keyValueMap[key] else { owsFailDebug("Missing value") continue } values.append(value) } return values } } // MARK: - // ImageEditorContents represents a snapshot of canvas // state. // // Instances of ImageEditorContents should be treated // as immutable, once configured. public class ImageEditorContents: NSObject { @objc public let imagePath: String @objc public let imageSizePixels: CGSize public typealias ItemMapType = OrderedDictionary // This represents the current state of each item, // a mapping of [itemId : item]. var itemMap = ItemMapType() // Used to create an initial, empty instances of this class. public init(imagePath: String, imageSizePixels: CGSize) { self.imagePath = imagePath self.imageSizePixels = imageSizePixels } // Used to clone copies of instances of this class. public init(imagePath: String, imageSizePixels: CGSize, itemMap: ItemMapType) { self.imagePath = imagePath self.imageSizePixels = imageSizePixels self.itemMap = itemMap } // Since the contents are immutable, we only modify copies // made with this method. public func clone() -> ImageEditorContents { return ImageEditorContents(imagePath: imagePath, imageSizePixels: imageSizePixels, itemMap: itemMap.clone()) } @objc public func item(forId itemId: String) -> ImageEditorItem? { return itemMap.value(forKey: itemId) } @objc public func append(item: ImageEditorItem) { Logger.verbose("\(item.itemId)") itemMap.append(key: item.itemId, value: item) } @objc public func replace(item: ImageEditorItem) { Logger.verbose("\(item.itemId)") itemMap.replace(key: item.itemId, value: item) } @objc public func remove(item: ImageEditorItem) { Logger.verbose("\(item.itemId)") itemMap.remove(key: item.itemId) } @objc public func remove(itemId: String) { Logger.verbose("\(itemId)") itemMap.remove(key: itemId) } @objc public func itemCount() -> Int { return itemMap.count } @objc public func items() -> [ImageEditorItem] { return itemMap.orderedValues() } } // MARK: - // Used to represent undo/redo operations. // // Because the image editor's "contents" and "items" // are immutable, these operations simply take a // snapshot of the current contents which can be used // (multiple times) to preserve/restore editor state. private class ImageEditorOperation: NSObject { let contents: ImageEditorContents required init(contents: ImageEditorContents) { self.contents = contents } } // MARK: - @objc public protocol ImageEditorModelDelegate: class { // Used for large changes to the model, when the entire // model should be reloaded. func imageEditorModelDidChange(before: ImageEditorContents, after: ImageEditorContents) // Used for small narrow changes to the model, usually // to a single item. func imageEditorModelDidChange(changedItemIds: [String]) } // MARK: - @objc public class ImageEditorModel: NSObject { @objc public static var isFeatureEnabled: Bool { return _isDebugAssertConfiguration() } @objc public weak var delegate: ImageEditorModelDelegate? @objc public let srcImagePath: String @objc public let srcImageSizePixels: CGSize private var contents: ImageEditorContents private var undoStack = [ImageEditorOperation]() private var redoStack = [ImageEditorOperation]() // We don't want to allow editing of images if: // // * They are invalid. // * We can't determine their size / aspect-ratio. @objc public required init(srcImagePath: String) throws { self.srcImagePath = srcImagePath let srcFileName = (srcImagePath as NSString).lastPathComponent let srcFileExtension = (srcFileName as NSString).pathExtension guard let mimeType = MIMETypeUtil.mimeType(forFileExtension: srcFileExtension) else { Logger.error("Couldn't determine MIME type for file.") throw ImageEditorError.invalidInput } guard MIMETypeUtil.isImage(mimeType), !MIMETypeUtil.isAnimated(mimeType) else { Logger.error("Invalid MIME type: \(mimeType).") throw ImageEditorError.invalidInput } let srcImageSizePixels = NSData.imageSize(forFilePath: srcImagePath, mimeType: mimeType) guard srcImageSizePixels.width > 0, srcImageSizePixels.height > 0 else { Logger.error("Couldn't determine image size.") throw ImageEditorError.invalidInput } self.srcImageSizePixels = srcImageSizePixels self.contents = ImageEditorContents(imagePath: srcImagePath, imageSizePixels: srcImageSizePixels) super.init() } @objc public var currentImagePath: String { return contents.imagePath } @objc public func itemCount() -> Int { return contents.itemCount() } @objc public func items() -> [ImageEditorItem] { return contents.items() } @objc public func item(forId itemId: String) -> ImageEditorItem? { return contents.item(forId: itemId) } @objc public func canUndo() -> Bool { return !undoStack.isEmpty } @objc public func canRedo() -> Bool { return !redoStack.isEmpty } @objc public func undo() { guard let undoOperation = undoStack.popLast() else { owsFailDebug("Cannot undo.") return } let redoOperation = ImageEditorOperation(contents: contents) redoStack.append(redoOperation) let oldContents = self.contents self.contents = undoOperation.contents // We could diff here and yield a more narrow change event. delegate?.imageEditorModelDidChange(before: oldContents, after: self.contents) } @objc public func redo() { guard let redoOperation = redoStack.popLast() else { owsFailDebug("Cannot redo.") return } let undoOperation = ImageEditorOperation(contents: contents) undoStack.append(undoOperation) let oldContents = self.contents self.contents = redoOperation.contents // We could diff here and yield a more narrow change event. delegate?.imageEditorModelDidChange(before: oldContents, after: self.contents) } @objc public func append(item: ImageEditorItem) { performAction({ (oldContents) in let newContents = oldContents.clone() newContents.append(item: item) return newContents }, changedItemIds: [item.itemId]) } @objc public func replace(item: ImageEditorItem, suppressUndo: Bool = false) { performAction({ (oldContents) in let newContents = oldContents.clone() newContents.replace(item: item) return newContents }, changedItemIds: [item.itemId], suppressUndo: suppressUndo) } @objc public func remove(item: ImageEditorItem) { performAction({ (oldContents) in let newContents = oldContents.clone() newContents.remove(item: item) return newContents }, changedItemIds: [item.itemId]) } // MARK: - Temp Files private var temporaryFilePaths = [String]() @objc public func temporaryFilePath(withFileExtension fileExtension: String) -> String { AssertIsOnMainThread() let filePath = OWSFileSystem.temporaryFilePath(withFileExtension: fileExtension) temporaryFilePaths.append(filePath) return filePath } deinit { AssertIsOnMainThread() let temporaryFilePaths = self.temporaryFilePaths DispatchQueue.global(qos: .background).async { for filePath in temporaryFilePaths { guard OWSFileSystem.deleteFile(filePath) else { Logger.error("Could not delete temp file: \(filePath)") continue } } } } // MARK: - Crop @objc public func crop(unitCropRect: CGRect) { guard let croppedImage = ImageEditorModel.crop(imagePath: contents.imagePath, unitCropRect: unitCropRect) else { // Not an error; user might have tapped or // otherwise drawn an invalid crop region. Logger.warn("Could not crop image.") return } // Use PNG for temp files; PNG is lossless. guard let croppedImageData = UIImagePNGRepresentation(croppedImage) else { owsFailDebug("Could not convert cropped image to PNG.") return } let croppedImagePath = temporaryFilePath(withFileExtension: "png") do { try croppedImageData.write(to: NSURL.fileURL(withPath: croppedImagePath), options: .atomicWrite) } catch let error as NSError { owsFailDebug("File write failed: \(error)") return } let croppedImageSizePixels = CGSizeScale(croppedImage.size, croppedImage.scale) let left = unitCropRect.origin.x let right = unitCropRect.origin.x + unitCropRect.size.width let top = unitCropRect.origin.y let bottom = unitCropRect.origin.y + unitCropRect.size.height let conversion: ImageEditorItem.PointConversionFunction = { (point) in // Convert from the pre-crop unit coordinate system // to post-crop unit coordinate system using inverse // lerp. // // NOTE: Some post-conversion unit values will _NOT_ // be clamped. e.g. strokes outside the crop // are that < 0 or > 1. This is fine. // We could hypothethically discard any items // whose bounding box is entirely outside the // new unit rectangle (e.g. have been completely // cropped) but it doesn't seem worthwhile. let converted = CGPoint(x: CGFloatInverseLerp(point.x, left, right), y: CGFloatInverseLerp(point.y, top, bottom)) return converted } performAction({ (oldContents) in let newContents = ImageEditorContents(imagePath: croppedImagePath, imageSizePixels: croppedImageSizePixels) for oldItem in oldContents.items() { let newItem = oldItem.clone(withPointConversionFunction: conversion) newContents.append(item: newItem) } return newContents }, changedItemIds: nil) } private func performAction(_ action: (ImageEditorContents) -> ImageEditorContents, changedItemIds: [String]?, suppressUndo: Bool = false) { if !suppressUndo { let undoOperation = ImageEditorOperation(contents: contents) undoStack.append(undoOperation) redoStack.removeAll() } let oldContents = self.contents let newContents = action(oldContents) contents = newContents if let changedItemIds = changedItemIds { delegate?.imageEditorModelDidChange(changedItemIds: changedItemIds) } else { delegate?.imageEditorModelDidChange(before: oldContents, after: self.contents) } } // MARK: - Utilities // Returns nil on error. private class func crop(imagePath: String, unitCropRect: CGRect) -> UIImage? { // TODO: Do we want to render off the main thread? AssertIsOnMainThread() guard let srcImage = UIImage(contentsOfFile: imagePath) else { owsFailDebug("Could not load image") return nil } let srcImageSize = srcImage.size // Convert from unit coordinates to src image coordinates. let cropRect = CGRect(x: round(unitCropRect.origin.x * srcImageSize.width), y: round(unitCropRect.origin.y * srcImageSize.height), width: round(unitCropRect.size.width * srcImageSize.width), height: round(unitCropRect.size.height * srcImageSize.height)) guard cropRect.origin.x >= 0, cropRect.origin.y >= 0, cropRect.origin.x + cropRect.size.width <= srcImageSize.width, cropRect.origin.y + cropRect.size.height <= srcImageSize.height else { owsFailDebug("Invalid crop rectangle.") return nil } guard cropRect.size.width > 0, cropRect.size.height > 0 else { // Not an error; indicates that the user tapped rather // than dragged. Logger.warn("Empty crop rectangle.") return nil } let hasAlpha = NSData.hasAlpha(forValidImageFilePath: imagePath) UIGraphicsBeginImageContextWithOptions(cropRect.size, !hasAlpha, srcImage.scale) defer { UIGraphicsEndImageContext() } guard let context = UIGraphicsGetCurrentContext() else { owsFailDebug("Could not create output context.") return nil } context.interpolationQuality = .high // Draw source image. let dstFrame = CGRect(origin: CGPointInvert(cropRect.origin), size: srcImageSize) srcImage.draw(in: dstFrame) let dstImage = UIGraphicsGetImageFromCurrentImageContext() if dstImage == nil { owsFailDebug("could not generate dst image.") } return dstImage } }