Implement crop.

pull/1/head
Matthew Chen 6 years ago
parent 57f888a447
commit db8bc58b6d

@ -43,6 +43,12 @@ public class ImageEditorItem: NSObject {
super.init()
}
public typealias PointConversionFunction = (CGPoint) -> CGPoint
public func clone(withPointConversionFunction conversion: PointConversionFunction) -> ImageEditorItem {
return ImageEditorItem(itemId: itemId, itemType: itemType)
}
}
// MARK: -
@ -110,6 +116,17 @@ public class ImageEditorStrokeItem: ImageEditorItem {
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: -
@ -222,6 +239,12 @@ public class OrderedDictionary<ValueType>: NSObject {
// as immutable, once configured.
public class ImageEditorContents: NSObject {
@objc
public let imagePath: String
@objc
public let imageSizePixels: CGSize
public typealias ItemMapType = OrderedDictionary<ImageEditorItem>
// This represents the current state of each item,
@ -229,18 +252,27 @@ public class ImageEditorContents: NSObject {
var itemMap = ItemMapType()
// Used to create an initial, empty instances of this class.
public override init() {
public init(imagePath: String,
imageSizePixels: CGSize) {
self.imagePath = imagePath
self.imageSizePixels = imageSizePixels
}
// Used to clone copies of instances of this class.
public init(itemMap: ItemMapType) {
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(itemMap: itemMap.clone())
return ImageEditorContents(imagePath: imagePath,
imageSizePixels: imageSizePixels,
itemMap: itemMap.clone())
}
@objc
@ -308,7 +340,8 @@ private class ImageEditorOperation: NSObject {
@objc
public protocol ImageEditorModelDelegate: class {
func imageEditorModelDidChange()
func imageEditorModelDidChange(before: ImageEditorContents,
after: ImageEditorContents)
func imageEditorModelDidChange(changedItemIds: [String])
}
@ -325,7 +358,7 @@ public class ImageEditorModel: NSObject {
@objc
public let srcImageSizePixels: CGSize
private var contents = ImageEditorContents()
private var contents: ImageEditorContents
private var undoStack = [ImageEditorOperation]()
private var redoStack = [ImageEditorOperation]()
@ -357,9 +390,17 @@ public class ImageEditorModel: NSObject {
}
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()
@ -395,10 +436,12 @@ public class ImageEditorModel: NSObject {
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()
delegate?.imageEditorModelDidChange(before: oldContents,
after: self.contents)
}
@objc
@ -411,37 +454,98 @@ public class ImageEditorModel: NSObject {
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()
delegate?.imageEditorModelDidChange(before: oldContents,
after: self.contents)
}
@objc
public func append(item: ImageEditorItem) {
performAction({ (newContents) in
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({ (newContents) in
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({ (newContents) in
performAction({ (oldContents) in
let newContents = oldContents.clone()
newContents.remove(item: item)
return newContents
}, changedItemIds: [item.itemId])
}
private func performAction(_ action: (ImageEditorContents) -> Void,
changedItemIds: [String],
@objc
public func crop(unitCropRect: CGRect) {
guard let croppedImage = ImageEditorModel.crop(imagePath: contents.imagePath,
unitCropRect: unitCropRect) else {
owsFailDebug("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 = OWSFileSystem.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)
@ -449,10 +553,69 @@ public class ImageEditorModel: NSObject {
redoStack.removeAll()
}
let newContents = contents.clone()
action(newContents)
let oldContents = self.contents
let newContents = action(oldContents)
contents = newContents
delegate?.imageEditorModelDidChange(changedItemIds: changedItemIds)
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: unitCropRect.origin.x * srcImageSize.width,
y: unitCropRect.origin.y * srcImageSize.height,
width: unitCropRect.size.width * srcImageSize.width,
height: 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 {
owsFailDebug("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
}
}

@ -33,28 +33,22 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate {
// MARK: - Views
private var imageView: UIImageView?
private let layersView = UIView()
private let imageView = UIImageView()
private var imageViewConstraints = [NSLayoutConstraint]()
private let layersView = OWSLayerView()
@objc
public func createImageView() -> Bool {
guard let image = UIImage(contentsOfFile: model.srcImagePath) else {
// TODO:
owsFailDebug("Could not load image")
return false
}
guard image.size.width > 0 && image.size.height > 0 else {
// TODO:
owsFailDebug("Could not load image")
self.addSubview(imageView)
guard updateImageView() else {
return false
}
let imageView = UIImageView(image: image)
imageView.layer.minificationFilter = kCAFilterTrilinear
imageView.layer.magnificationFilter = kCAFilterTrilinear
let aspectRatio = image.size.width / image.size.height
addSubviewWithScaleAspectFitLayout(view: imageView, aspectRatio: aspectRatio)
layersView.clipsToBounds = true
layersView.layoutCallback = { [weak self] (_) in
self?.updateAllContent()
}
self.addSubview(layersView)
layersView.autoPin(toEdgesOf: imageView)
@ -63,23 +57,46 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate {
let anyTouchGesture = ImageEditorGestureRecognizer(target: self, action: #selector(handleTouchGesture(_:)))
layersView.addGestureRecognizer(anyTouchGesture)
self.imageView = imageView
return true
}
private func addSubviewWithScaleAspectFitLayout(view: UIView, aspectRatio: CGFloat) {
self.addSubview(view)
@objc
public func updateImageView() -> Bool {
Logger.verbose("")
guard let image = UIImage(contentsOfFile: model.currentImagePath) else {
owsFailDebug("Could not load image")
return false
}
guard image.size.width > 0 && image.size.height > 0 else {
owsFailDebug("Could not load image")
return false
}
imageView.image = image
imageView.layer.minificationFilter = kCAFilterTrilinear
imageView.layer.magnificationFilter = kCAFilterTrilinear
let aspectRatio = image.size.width / image.size.height
for constraint in imageViewConstraints {
constraint.autoRemove()
}
imageViewConstraints = applyScaleAspectFitLayout(view: imageView, aspectRatio: aspectRatio)
return true
}
private func applyScaleAspectFitLayout(view: UIView, aspectRatio: CGFloat) -> [NSLayoutConstraint] {
// This emulates the behavior of contentMode = .scaleAspectFit using
// iOS auto layout constraints.
//
// This allows ConversationInputToolbar to place the "cancel" button
// in the upper-right hand corner of the preview content.
view.autoCenterInSuperview()
view.autoPin(toAspectRatio: aspectRatio)
view.autoMatch(.width, to: .width, of: self, withMultiplier: 1.0, relation: .lessThanOrEqual)
view.autoMatch(.height, to: .height, of: self, withMultiplier: 1.0, relation: .lessThanOrEqual)
var constraints = [NSLayoutConstraint]()
constraints.append(contentsOf: view.autoCenterInSuperview())
constraints.append(view.autoPin(toAspectRatio: aspectRatio))
constraints.append(view.autoMatch(.width, to: .width, of: self, withMultiplier: 1.0, relation: .lessThanOrEqual))
constraints.append(view.autoMatch(.height, to: .height, of: self, withMultiplier: 1.0, relation: .lessThanOrEqual))
return constraints
}
private let undoButton = UIButton(type: .custom)
@ -321,6 +338,16 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate {
cropLayer.removeFromSuperlayer()
cropLayer.removeAllAnimations()
}
// Find the upper-left and bottom-right corners of the
// crop rectangle, in unit coordinates.
let unitMin = CGPointClamp01(CGPointMin(self.cropStartUnit, self.cropEndUnit))
let unitMax = CGPointClamp01(CGPointMax(self.cropStartUnit, self.cropEndUnit))
let unitCropRect = CGRect(x: unitMin.x,
y: unitMin.y,
width: unitMax.x - unitMin.x,
height: unitMax.y - unitMin.y)
self.model.crop(unitCropRect: unitCropRect)
}
switch gestureRecognizer.state {
@ -343,7 +370,13 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate {
// MARK: - ImageEditorModelDelegate
public func imageEditorModelDidChange() {
public func imageEditorModelDidChange(before: ImageEditorContents,
after: ImageEditorContents) {
if before.imagePath != after.imagePath {
_ = updateImageView()
}
updateAllContent()
updateButtons()
@ -393,8 +426,9 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate {
bounds.height > 0 {
for item in model.items() {
let viewSize = layersView.bounds.size
guard let layer = ImageEditorView.layerForItem(item: item,
viewSize: bounds.size) else {
viewSize: viewSize) else {
continue
}
@ -467,7 +501,7 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate {
viewSize: CGSize) -> CALayer? {
AssertIsOnMainThread()
Logger.verbose("\(item.itemId)")
Logger.verbose("\(item.itemId), viewSize: \(viewSize)")
let strokeWidth = ImageEditorStrokeItem.strokeWidth(forUnitStrokeWidth: item.unitStrokeWidth,
dstSize: viewSize)
@ -591,9 +625,9 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate {
// Render output at same size as source image.
let dstSizePixels = model.srcImageSizePixels
let hasAlpha = NSData.hasAlpha(forValidImageFilePath: model.srcImagePath)
let hasAlpha = NSData.hasAlpha(forValidImageFilePath: model.currentImagePath)
guard let srcImage = UIImage(contentsOfFile: model.srcImagePath) else {
guard let srcImage = UIImage(contentsOfFile: model.currentImagePath) else {
owsFailDebug("Could not load src image.")
return nil
}

@ -6,10 +6,17 @@ import Foundation
@objc
public class OWSLayerView: UIView {
let layoutCallback: ((UIView) -> Void)
public var layoutCallback: ((UIView) -> Void)
@objc
public required init(frame: CGRect, layoutCallback : @escaping (UIView) -> Void) {
public init() {
self.layoutCallback = { (_) in
}
super.init(frame: .zero)
}
@objc
public init(frame: CGRect, layoutCallback : @escaping (UIView) -> Void) {
self.layoutCallback = layoutCallback
super.init(frame: frame)
}

@ -2,6 +2,7 @@
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
//
#import "OWSMath.h"
#import <PureLayout/PureLayout.h>
#import <UIKit/UIKit.h>
@ -153,75 +154,6 @@ CGFloat ScaleFromIPhone5(CGFloat iPhone5Value);
#pragma mark - Macros
CG_INLINE CGSize CGSizeCeil(CGSize size)
{
return CGSizeMake((CGFloat)ceil(size.width), (CGFloat)ceil(size.height));
}
CG_INLINE CGSize CGSizeFloor(CGSize size)
{
return CGSizeMake((CGFloat)floor(size.width), (CGFloat)floor(size.height));
}
CG_INLINE CGSize CGSizeRound(CGSize size)
{
return CGSizeMake((CGFloat)round(size.width), (CGFloat)round(size.height));
}
CG_INLINE CGSize CGSizeMax(CGSize size1, CGSize size2)
{
return CGSizeMake(MAX(size1.width, size2.width), MAX(size1.height, size2.height));
}
CG_INLINE CGPoint CGPointAdd(CGPoint left, CGPoint right)
{
return CGPointMake(left.x + right.x, left.y + right.y);
}
CG_INLINE CGPoint CGPointSubtract(CGPoint left, CGPoint right)
{
return CGPointMake(left.x - right.x, left.y - right.y);
}
CG_INLINE CGPoint CGPointScale(CGPoint point, CGFloat factor)
{
return CGPointMake(point.x * factor, point.y * factor);
}
CG_INLINE CGFloat CGPointDistance(CGPoint left, CGPoint right)
{
CGPoint delta = CGPointSubtract(left, right);
return sqrt(delta.x * delta.x + delta.y * delta.y);
}
CG_INLINE CGPoint CGPointMin(CGPoint left, CGPoint right)
{
return CGPointMake(MIN(left.x, right.x), MIN(left.y, right.y));
}
CG_INLINE CGPoint CGPointMax(CGPoint left, CGPoint right)
{
return CGPointMake(MAX(left.x, right.x), MAX(left.y, right.y));
}
CG_INLINE CGSize CGSizeScale(CGSize size, CGFloat factor)
{
return CGSizeMake(size.width * factor, size.height * factor);
}
CG_INLINE CGSize CGSizeAdd(CGSize left, CGSize right)
{
return CGSizeMake(left.width + right.width, left.height + right.height);
}
CG_INLINE CGRect CGRectScale(CGRect rect, CGFloat factor)
{
CGRect result;
result.origin = CGPointScale(rect.origin, factor);
result.size = CGSizeScale(rect.size, factor);
return result;
}
CGFloat CGHairlineWidth(void);
NS_ASSUME_NONNULL_END

@ -5,32 +5,111 @@
NS_ASSUME_NONNULL_BEGIN
// TODO: We'll eventually want to promote these into an OWSMath.h header.
static inline CGFloat CGFloatClamp(CGFloat value, CGFloat minValue, CGFloat maxValue)
CG_INLINE CGFloat CGFloatClamp(CGFloat value, CGFloat minValue, CGFloat maxValue)
{
return MAX(minValue, MIN(maxValue, value));
}
static inline CGFloat CGFloatClamp01(CGFloat value)
CG_INLINE CGFloat CGFloatClamp01(CGFloat value)
{
return CGFloatClamp(value, 0.f, 1.f);
}
static inline CGFloat CGFloatLerp(CGFloat left, CGFloat right, CGFloat alpha)
CG_INLINE CGFloat CGFloatLerp(CGFloat left, CGFloat right, CGFloat alpha)
{
alpha = CGFloatClamp01(alpha);
return (left * (1.f - alpha)) + (right * alpha);
}
static inline CGFloat CGFloatInverseLerp(CGFloat value, CGFloat minValue, CGFloat maxValue)
CG_INLINE CGFloat CGFloatInverseLerp(CGFloat value, CGFloat minValue, CGFloat maxValue)
{
return (value - minValue) / (maxValue - minValue);
}
// Ceil to an even number
static inline CGFloat CeilEven(CGFloat value)
CG_INLINE CGFloat CeilEven(CGFloat value)
{
return 2.f * (CGFloat)ceil(value * 0.5f);
}
CG_INLINE CGSize CGSizeCeil(CGSize size)
{
return CGSizeMake((CGFloat)ceil(size.width), (CGFloat)ceil(size.height));
}
CG_INLINE CGSize CGSizeFloor(CGSize size)
{
return CGSizeMake((CGFloat)floor(size.width), (CGFloat)floor(size.height));
}
CG_INLINE CGSize CGSizeRound(CGSize size)
{
return CGSizeMake((CGFloat)round(size.width), (CGFloat)round(size.height));
}
CG_INLINE CGSize CGSizeMax(CGSize size1, CGSize size2)
{
return CGSizeMake(MAX(size1.width, size2.width), MAX(size1.height, size2.height));
}
CG_INLINE CGPoint CGPointAdd(CGPoint left, CGPoint right)
{
return CGPointMake(left.x + right.x, left.y + right.y);
}
CG_INLINE CGPoint CGPointSubtract(CGPoint left, CGPoint right)
{
return CGPointMake(left.x - right.x, left.y - right.y);
}
CG_INLINE CGPoint CGPointScale(CGPoint point, CGFloat factor)
{
return CGPointMake(point.x * factor, point.y * factor);
}
CG_INLINE CGFloat CGPointDistance(CGPoint left, CGPoint right)
{
CGPoint delta = CGPointSubtract(left, right);
return sqrt(delta.x * delta.x + delta.y * delta.y);
}
CG_INLINE CGPoint CGPointMin(CGPoint left, CGPoint right)
{
return CGPointMake(MIN(left.x, right.x), MIN(left.y, right.y));
}
CG_INLINE CGPoint CGPointMax(CGPoint left, CGPoint right)
{
return CGPointMake(MAX(left.x, right.x), MAX(left.y, right.y));
}
CG_INLINE CGPoint CGPointClamp01(CGPoint point)
{
return CGPointMake(CGFloatClamp01(point.x), CGFloatClamp01(point.y));
}
CG_INLINE CGPoint CGPointInvert(CGPoint point)
{
return CGPointMake(-point.x, -point.y);
}
CG_INLINE CGSize CGSizeScale(CGSize size, CGFloat factor)
{
return CGSizeMake(size.width * factor, size.height * factor);
}
CG_INLINE CGSize CGSizeAdd(CGSize left, CGSize right)
{
return CGSizeMake(left.width + right.width, left.height + right.height);
}
CG_INLINE CGRect CGRectScale(CGRect rect, CGFloat factor)
{
CGRect result;
result.origin = CGPointScale(rect.origin, factor);
result.size = CGSizeScale(rect.size, factor);
return result;
}
NS_ASSUME_NONNULL_END

Loading…
Cancel
Save