Browse Source

Add new library

Marino Faggiana 6 years ago
parent
commit
273bf57b62
35 changed files with 3174 additions and 0 deletions
  1. 34 0
      Libraries external/PDFGenerator/DPIType.swift
  2. 36 0
      Libraries external/PDFGenerator/FilePathConvertible.swift
  3. 39 0
      Libraries external/PDFGenerator/PDFGenerateError.swift
  4. 19 0
      Libraries external/PDFGenerator/PDFGenerator.h
  5. 290 0
      Libraries external/PDFGenerator/PDFGenerator.swift
  6. 77 0
      Libraries external/PDFGenerator/PDFPage.swift
  7. 149 0
      Libraries external/PDFGenerator/PDFPageRenderable.swift
  8. 67 0
      Libraries external/PDFGenerator/PDFPassword.swift
  9. 188 0
      Nextcloud.xcodeproj/project.pbxproj
  10. 5 0
      iOSClient/AppDelegate.m
  11. 1 0
      iOSClient/CCGlobal.h
  12. 67 0
      iOSClient/Library/WeScan/Common/EditScanCornerView.swift
  13. 38 0
      iOSClient/Library/WeScan/Common/Error.swift
  14. 173 0
      iOSClient/Library/WeScan/Common/Quadrilateral.swift
  15. 301 0
      iOSClient/Library/WeScan/Common/QuadrilateralView.swift
  16. 34 0
      iOSClient/Library/WeScan/Common/RectangleDetector.swift
  17. 193 0
      iOSClient/Library/WeScan/Edit/EditScanViewController.swift
  18. 60 0
      iOSClient/Library/WeScan/Edit/ZoomGestureController.swift
  19. 34 0
      iOSClient/Library/WeScan/Extensions/AVCaptureVideoOrientation+Utils.swift
  20. 26 0
      iOSClient/Library/WeScan/Extensions/Array+Utils.swift
  21. 35 0
      iOSClient/Library/WeScan/Extensions/CGAffineTransform+Utils.swift
  22. 69 0
      iOSClient/Library/WeScan/Extensions/CGPoint+Utils.swift
  23. 27 0
      iOSClient/Library/WeScan/Extensions/CGRect+Utils.swift
  24. 54 0
      iOSClient/Library/WeScan/Extensions/CIRectangleFeature+Utils.swift
  25. 74 0
      iOSClient/Library/WeScan/Extensions/UIImage+Orientation.swift
  26. 34 0
      iOSClient/Library/WeScan/Extensions/UIImage+Utils.swift
  27. 118 0
      iOSClient/Library/WeScan/ImageScannerController.swift
  28. 41 0
      iOSClient/Library/WeScan/Protocols/Transformable.swift
  29. 83 0
      iOSClient/Library/WeScan/Review/ReviewViewController.swift
  30. 289 0
      iOSClient/Library/WeScan/Scan/CaptureSessionManager.swift
  31. 48 0
      iOSClient/Library/WeScan/Scan/CloseButton.swift
  32. 168 0
      iOSClient/Library/WeScan/Scan/RectangleFeaturesFunnel.swift
  33. 185 0
      iOSClient/Library/WeScan/Scan/ScannerViewController.swift
  34. 101 0
      iOSClient/Library/WeScan/Scan/ShutterButton.swift
  35. 17 0
      iOSClient/Library/WeScan/WeScan.h

+ 34 - 0
Libraries external/PDFGenerator/DPIType.swift

@@ -0,0 +1,34 @@
+//
+//  DPIType.swift
+//  PDFGenerator
+//
+//  Created by Suguru Kishimoto on 2016/06/21.
+//
+//
+
+import Foundation
+import UIKit
+
+public enum DPIType {
+    fileprivate static let defaultDpi: CGFloat = 72.0
+    case `default`
+    case dpi_300
+    case custom(CGFloat)
+    
+    public var value: CGFloat {
+        switch self {
+        case .default:
+            return type(of: self).defaultDpi
+        case .dpi_300:
+            return 300.0
+        case .custom(let value) where value > 1.0:
+            return value
+        default:
+            return DPIType.default.value
+        }
+    }
+    
+    public var scaleFactor: CGFloat {
+        return self.value / DPIType.default.value
+    }
+}

+ 36 - 0
Libraries external/PDFGenerator/FilePathConvertible.swift

@@ -0,0 +1,36 @@
+//
+//  FilePathConvertible.swift
+//  PDFGenerator
+//
+//  Created by Suguru Kishimoto on 7/23/16.
+//
+//
+
+import Foundation
+
+public protocol FilePathConvertible {
+    var url: URL { get }
+    var path: String { get }
+}
+
+extension FilePathConvertible {
+    var isEmptyPath: Bool {
+        return path.isEmpty
+    }
+}
+
+extension String: FilePathConvertible {
+    public var url: URL {
+        return URL(fileURLWithPath: self)
+    }
+    
+    public var path: String {
+        return self
+    }
+}
+
+extension URL: FilePathConvertible {
+    public var url: URL {
+        return self
+    }
+}

+ 39 - 0
Libraries external/PDFGenerator/PDFGenerateError.swift

@@ -0,0 +1,39 @@
+//
+//  PDFGenerateError.swift
+//  PDFGenerator
+//
+//  Created by Suguru Kishimoto on 2016/06/21.
+//
+//
+
+import Foundation
+
+/**
+ PDFGenerateError
+ 
+ - ZeroSizeView:    View's size is (0, 0)
+ - ImageLoadFailed: Image has not been loaded from image path.
+ - EmptyOutputPath: Output path is empty.
+ - EmptyPage:       Create PDF from no pages.
+ - InvalidContext:  If UIGraphicsGetCurrentContext returns nil.
+ - InvalidPassword: If password cannot covert ASCII text.
+ - TooLongPassword: If password too long
+ */
+public enum PDFGenerateError: Error {
+    /// View's size is (0, 0)
+    case zeroSizeView(UIView)
+    /// Image has not been loaded from image path.
+    case imageLoadFailed(Any)
+    /// Output path is empty
+    case emptyOutputPath
+    /// Attempt to create empty PDF. (no pages.)
+    case emptyPage
+    /// If UIGraphicsGetCurrentContext returns nil.
+    case invalidContext
+    /// If rendering scale factor is zero.
+    case invalidScaleFactor
+    /// If password cannot covert ASCII text.
+    case invalidPassword(String)
+    /// If password too long
+    case tooLongPassword(Int)
+}

+ 19 - 0
Libraries external/PDFGenerator/PDFGenerator.h

@@ -0,0 +1,19 @@
+//
+//  PDFGenerator.h
+//  PDFGenerator
+//
+//  Created by Suguru Kishimoto on 2016/02/04.
+//
+//
+
+#import <UIKit/UIKit.h>
+
+//! Project version number for PDFGenerator.
+FOUNDATION_EXPORT double PDFGeneratorVersionNumber;
+
+//! Project version string for PDFGenerator.
+FOUNDATION_EXPORT const unsigned char PDFGeneratorVersionString[];
+
+// In this header, you should import all the public headers of your framework using statements like #import <PDFGenerator/PublicHeader.h>
+
+

+ 290 - 0
Libraries external/PDFGenerator/PDFGenerator.swift

@@ -0,0 +1,290 @@
+//
+//  PDFGenerator.swift
+//  PDFGenerator
+//
+//  Created by Suguru Kishimoto on 2016/02/04.
+//
+//
+
+import Foundation
+import UIKit
+
+/// PDFGenerator
+public final class PDFGenerator {
+    fileprivate typealias Process = () throws -> Void
+    
+    /// Avoid creating instance.
+    fileprivate init() {}
+    
+    /**
+     Generate from page object.
+     
+     - parameter page:       A `PDFPage`'s object.
+     - parameter outputPath: An outputPath to save PDF.
+     
+     - throws: A `PDFGenerateError` thrown if some error occurred.
+     */
+    public class func generate(_ page: PDFPage, to path: FilePathConvertible, dpi: DPIType = .default, password: PDFPassword = "") throws {
+        try generate([page], to: path, dpi: dpi, password: password)
+    }
+    
+    /**
+     Generate from page objects.
+     
+     - parameter pages:      Array of `PDFPage`'s objects.
+     - parameter outputPath: An outputPath to save PDF.
+     
+     - throws: A `PDFGenerateError` thrown if some error occurred.
+     */
+    public class func generate(_ pages: [PDFPage], to path: FilePathConvertible, dpi: DPIType = .default, password: PDFPassword = "") throws {
+        guard !pages.isEmpty else {
+            throw PDFGenerateError.emptyPage
+        }
+        guard !path.isEmptyPath else {
+            throw PDFGenerateError.emptyOutputPath
+        }
+        do {
+            try render(to: path, password: password) {
+                try render(pages, dpi: dpi)
+            }
+        } catch let error {
+            _ = try? FileManager.default.removeItem(at: path.url)
+            throw error
+        }
+    }
+    
+    /**
+     Generate from view.
+     
+     - parameter view:       A view
+     - parameter outputPath: An outputPath to save PDF.
+     
+     - throws: A `PDFGenerateError` thrown if some error occurred.
+     */
+    public class func generate(_ view: UIView, to path: FilePathConvertible, dpi: DPIType = .default, password: PDFPassword = "") throws {
+        try generate([view], to: path, dpi: dpi, password: password)
+    }
+    
+    /**
+     Generate from views.
+     
+     - parameter views:      Array of views.
+     - parameter outputPath: An outputPath to save PDF.
+     
+     - throws: A `PDFGenerateError` thrown if some error occurred.
+     */
+    public class func generate(_ views: [UIView], to path: FilePathConvertible, dpi: DPIType = .default, password: PDFPassword = "") throws {
+        try generate(PDFPage.pages(views), to: path, dpi: dpi, password: password)
+    }
+    
+    /**
+     Generate from image.
+     
+     - parameter image:      An image.
+     - parameter outputPath: An outputPath to save PDF.
+     
+     - throws: A `PDFGenerateError` thrown if some error occurred.
+     */
+    public class func generate(_ image: UIImage, to path: FilePathConvertible, dpi: DPIType = .default, password: PDFPassword = "") throws {
+        try generate([image], to: path, dpi: dpi, password: password)
+    }
+    
+    /**
+     Generate from images.
+     
+     - parameter images:     Array of images.
+     - parameter outputPath: An outputPath to save PDF.
+     
+     - throws: A `PDFGenerateError` thrown if some error occurred.
+     */
+    public class func generate(_ images: [UIImage], to path: FilePathConvertible, dpi: DPIType = .default, password: PDFPassword = "") throws {
+        try generate(PDFPage.pages(images), to: path, dpi: dpi, password: password)
+    }
+
+    /**
+     Generate from image path.
+     
+     - parameter imagePath:  An image path.
+     - parameter outputPath: An outputPath to save PDF.
+     
+     - throws: A `PDFGenerateError` thrown if some error occurred.
+     */
+    public class func generate(_ imagePath: String, to path: FilePathConvertible, dpi: DPIType = .default, password: PDFPassword = "") throws {
+        try generate([imagePath], to: path, dpi: dpi, password: password)
+    }
+    
+    /**
+     Generate from image paths.
+     
+     - parameter imagePaths: Arrat of image paths.
+     - parameter outputPath: An outputPath to save PDF.
+     
+     - throws: A `PDFGenerateError` thrown if some error occurred.
+     */
+    public class func generate(_ imagePaths: [String], to path: FilePathConvertible, dpi: DPIType = .default, password: PDFPassword = "") throws {
+        try generate(PDFPage.pages(imagePaths), to: path, dpi: dpi, password: password)
+    }
+    
+    /**
+     Generate from page object.
+     
+     - parameter page: A `PDFPage`'s object.
+     
+     - throws: A `PDFGenerateError` thrown if some error occurred.
+     
+     - returns: PDF's binary data (NSData)
+     */
+    
+    public class func generated(by page: PDFPage, dpi: DPIType = .default, password: PDFPassword = "") throws -> Data {
+        return try generated(by: [page], dpi: dpi, password: password)
+    }
+
+    /**
+     Generate from page objects.
+     
+     - parameter pages: Array of `PDFPage`'s objects.
+     
+     - throws: A `PDFGenerateError` thrown if some error occurred.
+     
+     - returns: PDF's binary data (NSData)
+     */
+    
+    public class func generated(by pages: [PDFPage], dpi: DPIType = .default, password: PDFPassword = "") throws -> Data {
+        guard !pages.isEmpty else {
+            throw PDFGenerateError.emptyPage
+        }
+        return try rendered(with: password) { try render(pages, dpi: dpi) }
+    }
+
+    /**
+     Generate from view.
+     
+     - parameter view: A view
+     
+     - throws: A `PDFGenerateError` thrown if some error occurred.
+     
+     - returns: PDF's binary data (NSData)
+     */
+    
+    public class func generated(by view: UIView, dpi: DPIType = .default, password: PDFPassword = "") throws -> Data {
+        return try generated(by: [view], dpi: dpi, password: password)
+    }
+
+    /**
+     Generate from views.
+     
+     - parameter views: Array of views.
+     
+     - throws: A `PDFGenerateError` thrown if some error occurred.
+     
+     - returns: PDF's binary data (NSData)
+     */
+    
+    public class func generated(by views: [UIView], dpi: DPIType = .default, password: PDFPassword = "") throws -> Data  {
+        return try generated(by: PDFPage.pages(views), dpi: dpi, password: password)
+    }
+    
+    /**
+     Generate from image.
+     
+     - parameter image: An image.
+     
+     - throws: A `PDFGenerateError` thrown if some error occurred.
+     
+     - returns: PDF's binary data (NSData)
+     */
+    
+    public class func generated(by image: UIImage, dpi: DPIType = .default, password: PDFPassword = "") throws -> Data {
+        return try generated(by: [image], dpi: dpi, password: password)
+    }
+
+    /**
+     Generate from images.
+     
+     - parameter images: Array of images.
+     
+     - throws: A `PDFGenerateError` thrown if some error occurred.
+     
+     - returns: PDF's binary data (NSData)
+     */
+    
+    public class func generated(by images: [UIImage], dpi: DPIType = .default, password: PDFPassword = "") throws -> Data {
+        return try generated(by: PDFPage.pages(images), dpi: dpi, password: password)
+    }
+    
+    /**
+     Generate from image path.
+     
+     - parameter imagePath: An image path.
+     
+     - throws: A `PDFGenerateError` thrown if some error occurred.
+     
+     - returns: PDF's binary data (NSData)
+     */
+    
+    public class func generated(by imagePath: String, dpi: DPIType = .default, password: PDFPassword = "") throws -> Data {
+        return try generated(by: [imagePath], dpi: dpi, password: password)
+    }
+    
+    /**
+     Generate from image paths.
+     
+     - parameter imagePaths: Arrat of image paths.
+     
+     - throws: A `PDFGenerateError` thrown if some error occurred.
+     
+     - returns: PDF's binary data (NSData)
+     */
+    
+    public class func generated(by imagePaths: [String], dpi: DPIType = .default, password: PDFPassword = "") throws -> Data {
+        return try generated(by: PDFPage.pages(imagePaths), dpi: dpi, password: password)
+    }
+}
+
+// MARK: Private Extension
+
+/// PDFGenerator private extensions (render processes)
+private extension PDFGenerator {
+    class func render(_ page: PDFPage, dpi: DPIType) throws {
+        let scaleFactor = dpi.scaleFactor
+        
+        try autoreleasepool {
+            switch page {
+            case .whitePage(let size):
+                let view = UIView(frame: CGRect(origin: .zero, size: size))
+                view.backgroundColor = .white
+                try view.renderPDFPage(scaleFactor: scaleFactor)
+            case .view(let view):
+                try view.renderPDFPage(scaleFactor: scaleFactor)
+            case .image(let image):
+                try image.asUIImage().renderPDFPage(scaleFactor: scaleFactor)
+            case .imagePath(let ip):
+                try ip.asUIImage().renderPDFPage(scaleFactor: scaleFactor)
+            case .binary(let data):
+                try data.asUIImage().renderPDFPage(scaleFactor: scaleFactor)
+            case .imageRef(let cgImage):
+                try cgImage.asUIImage().renderPDFPage(scaleFactor: scaleFactor)
+            }
+        }
+    }
+    
+    class func render(_ pages: [PDFPage], dpi: DPIType) throws {
+        try pages.forEach { try render($0, dpi: dpi) }
+    }
+    
+    class func render(to path: FilePathConvertible, password: PDFPassword, process: Process) rethrows {
+        try { try password.verify() }()
+        UIGraphicsBeginPDFContextToFile(path.path, .zero, password.toDocumentInfo())
+        try process()
+        UIGraphicsEndPDFContext()
+    }
+    
+    class func rendered(with password: PDFPassword, process: Process) rethrows -> Data {
+        try { try password.verify() }()
+        let data = NSMutableData()
+        UIGraphicsBeginPDFContextToData(data, .zero, password.toDocumentInfo())
+        try process()
+        UIGraphicsEndPDFContext()
+        return data as Data
+    }
+}

+ 77 - 0
Libraries external/PDFGenerator/PDFPage.swift

@@ -0,0 +1,77 @@
+//
+//  PDFPage.swift
+//  PDFGenerator
+//
+//  Created by Suguru Kishimoto on 2016/06/21.
+//
+//
+
+import Foundation
+import UIKit
+
+/**
+ PDF page model.
+ 
+ - WhitePage: A white view (CGSize)
+ - View:      A view. (UIView)
+ - Image:     An image (UIImage)
+ - ImagePath: ImagePath: An image path (String)
+ - Binary:    Binary data (NSData)
+ - ImageRef:  Image ref (CGImage)
+ */
+public enum PDFPage {
+    /// A white view (CGSize)
+    case whitePage(CGSize)
+    /// A view. (UIView)
+    case view(UIView)
+    /// An image (UIImage)
+    case image(UIImage)
+    /// ImagePath: An image path (String)
+    case imagePath(String)
+    /// Binary data (NSData)
+    case binary(Data)
+    /// Image ref (CGImage)
+    case imageRef(CGImage)
+    
+    /**
+     Convert views to PDFPage models.
+     
+     - parameter views: Array of `UIVIew`
+     
+     - returns: Array of `PDFPage`
+     */
+    static func pages(_ views: [UIView]) -> [PDFPage] {
+        return views.map { .view($0) }
+    }
+    
+    /**
+     Convert images to PDFPage models.
+     
+     - parameter views: Array of `UIImage`
+     
+     - returns: Array of `PDFPage`
+     */
+    static func pages(_ images: [UIImage]) -> [PDFPage] {
+        return images.map { .image($0) }
+    }
+    
+    /**
+     Convert image path to PDFPage models.
+     
+     - parameter views: Array of `String`(image path)
+     
+     - returns: Array of `PDFPage`
+     */
+    static func pages(_ imagePaths: [String]) -> [PDFPage] {
+        return imagePaths.map { .imagePath($0) }
+    }
+}
+
+/// PDF page size (pixel, 72dpi)
+public struct PDFPageSize {
+    fileprivate init() {}
+    /// A4
+    public static let A4 = CGSize(width: 595.0, height: 842.0)
+    /// B5
+    public static let B5 = CGSize(width: 516.0, height: 729.0)
+}

+ 149 - 0
Libraries external/PDFGenerator/PDFPageRenderable.swift

@@ -0,0 +1,149 @@
+//
+//  PDFPageRenderable.swift
+//  PDFGenerator
+//
+//  Created by Suguru Kishimoto on 2016/02/10.
+//
+//
+
+import Foundation
+import UIKit
+import WebKit
+
+protocol PDFPageRenderable {
+    func renderPDFPage(scaleFactor: CGFloat) throws
+}
+
+private extension UIScrollView {
+    typealias TempInfo = (frame: CGRect, offset: CGPoint, inset: UIEdgeInsets)
+    
+    var tempInfo: TempInfo {
+        return (frame, contentOffset, contentInset)
+    }
+    
+    func transformForRender() {
+        contentOffset = .zero
+        contentInset = UIEdgeInsets.zero
+        frame = CGRect(origin: .zero, size: contentSize)
+    }
+    
+    func restore(_ info: TempInfo) {
+        frame = info.frame
+        contentOffset = info.offset
+        contentInset = info.inset
+    }
+    
+}
+
+extension UIView: PDFPageRenderable {    
+    fileprivate func _render<T: UIView>(_ view: T, scaleFactor: CGFloat, completion: (T) -> Void = { _ in }) throws {
+        guard scaleFactor > 0.0 else {
+            throw PDFGenerateError.invalidScaleFactor
+        }
+        
+        let size = getPageSize()
+        guard size.width > 0 && size.height > 0 else {
+            throw PDFGenerateError.zeroSizeView(self)
+        }
+        guard let context = UIGraphicsGetCurrentContext() else {
+            throw PDFGenerateError.invalidContext
+        }
+
+        let renderFrame = CGRect(origin: .zero, size: CGSize(width: size.width * scaleFactor, height: size.height * scaleFactor))
+        autoreleasepool {
+            let superView = view.superview
+            view.removeFromSuperview()
+            UIGraphicsBeginPDFPageWithInfo(renderFrame, nil)
+            view.layer.render(in: context)
+            superView?.addSubview(view)
+            superView?.layoutIfNeeded()
+            completion(view)
+        }
+    }
+    
+    func renderPDFPage(scaleFactor: CGFloat) throws {
+        func renderScrollView(_ scrollView: UIScrollView) throws {
+            let tmp = scrollView.tempInfo
+            scrollView.transformForRender()
+            try _render(scrollView, scaleFactor: scaleFactor) { scrollView in
+                scrollView.restore(tmp)
+            }
+        }
+        
+        if let webView = self as? UIWebView {
+            try renderScrollView(webView.scrollView)
+        } else if let webView = self as? WKWebView {
+            try renderScrollView(webView.scrollView)
+        } else if let scrollView = self as? UIScrollView {
+            try renderScrollView(scrollView)
+        } else {
+            try _render(self, scaleFactor: scaleFactor)
+        }
+    }
+    
+    fileprivate func getPageSize() -> CGSize {
+        switch self {
+        case (let webView as UIWebView):
+            return webView.scrollView.contentSize
+        case (let webView as WKWebView):
+            return webView.scrollView.contentSize
+        case (let scrollView as UIScrollView):
+            return scrollView.contentSize
+        default:
+            return self.frame.size
+        }
+    }
+}
+
+extension UIImage: PDFPageRenderable {
+    func renderPDFPage(scaleFactor: CGFloat) throws {
+        guard scaleFactor > 0.0 else {
+            throw PDFGenerateError.invalidScaleFactor
+        }
+        autoreleasepool {
+            let bounds = CGRect(
+                origin: .zero,
+                size: CGSize(
+                    width: size.width * scaleFactor,
+                    height: size.height * scaleFactor
+                )
+            )
+            UIGraphicsBeginPDFPageWithInfo(bounds, nil)
+            draw(in: bounds)
+        }
+    }
+}
+
+protocol UIImageConvertible {
+    func asUIImage() throws -> UIImage
+}
+
+extension UIImage: UIImageConvertible {
+    func asUIImage() throws -> UIImage {
+        return self
+    }
+}
+
+extension String: UIImageConvertible {
+    func asUIImage() throws -> UIImage {
+        guard let image = UIImage(contentsOfFile: self) else{
+            throw PDFGenerateError.imageLoadFailed(self)
+        }
+        return image
+    }
+}
+
+extension Data: UIImageConvertible {
+    func asUIImage() throws -> UIImage {
+        guard let image = UIImage(data: self) else {
+            throw PDFGenerateError.imageLoadFailed(self)
+        }
+        return image
+    }
+}
+
+extension CGImage: UIImageConvertible {
+    func asUIImage() throws -> UIImage {
+        return UIImage(cgImage: self)
+    }
+}

+ 67 - 0
Libraries external/PDFGenerator/PDFPassword.swift

@@ -0,0 +1,67 @@
+//
+//  PDFPassword.swift
+//  PDFGenerator
+//
+//  Created by Suguru Kishimoto on 2016/07/08.
+//
+//
+
+import Foundation
+import UIKit
+
+public struct PDFPassword {
+    static let NoPassword = ""
+    fileprivate static let PasswordLengthMax = 32
+    let userPassword: String
+    let ownerPassword: String
+    
+    public init(user userPassword: String, owner ownerPassword: String) {
+        self.userPassword = userPassword
+        self.ownerPassword = ownerPassword
+    }
+    
+    public init(_ password: String) {
+        self.init(user: password, owner: password)
+    }
+    
+    func toDocumentInfo() -> [AnyHashable : Any] {
+        var info: [AnyHashable : Any] = [:]
+        if userPassword != type(of: self).NoPassword {
+            info[String(kCGPDFContextUserPassword)] = userPassword
+        }
+        if ownerPassword != type(of: self).NoPassword {
+            info[String(kCGPDFContextOwnerPassword)] = ownerPassword
+        }
+        return info
+    }
+    
+    func verify() throws {
+        guard userPassword.canBeConverted(to: String.Encoding.ascii) else {
+            throw PDFGenerateError.invalidPassword(userPassword)
+        }
+        guard userPassword.count <= type(of: self).PasswordLengthMax else {
+            throw PDFGenerateError.tooLongPassword(userPassword.count)
+        }
+        
+        guard ownerPassword.canBeConverted(to: String.Encoding.ascii) else {
+            throw PDFGenerateError.invalidPassword(ownerPassword)
+        }
+        guard ownerPassword.count <= type(of: self).PasswordLengthMax else {
+            throw PDFGenerateError.tooLongPassword(ownerPassword.count)
+        }
+    }
+}
+
+extension PDFPassword: ExpressibleByStringLiteral {
+    public init(unicodeScalarLiteral value: String) {
+        self.init(value)
+    }
+    
+    public init(extendedGraphemeClusterLiteral value: String) {
+        self.init(value)
+    }
+    
+    public init(stringLiteral value: String) {
+        self.init(value)
+    }
+}

+ 188 - 0
Nextcloud.xcodeproj/project.pbxproj

@@ -246,6 +246,36 @@
 		F75037511DBFA91A008FB480 /* NSLayoutConstraint+PureLayout.m in Sources */ = {isa = PBXBuildFile; fileRef = F75037481DBFA91A008FB480 /* NSLayoutConstraint+PureLayout.m */; };
 		F755BD9B20594AC7008C5FBB /* NCService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F755BD9A20594AC7008C5FBB /* NCService.swift */; };
 		F75797AE1E81356C00187A1B /* CTAssetsPicker.strings in Resources */ = {isa = PBXBuildFile; fileRef = F75797AC1E81356C00187A1B /* CTAssetsPicker.strings */; };
+		F758B3E1212C4A6C00515F55 /* PDFPageRenderable.swift in Sources */ = {isa = PBXBuildFile; fileRef = F758B3D9212C4A6C00515F55 /* PDFPageRenderable.swift */; };
+		F758B3E2212C4A6C00515F55 /* DPIType.swift in Sources */ = {isa = PBXBuildFile; fileRef = F758B3DA212C4A6C00515F55 /* DPIType.swift */; };
+		F758B3E3212C4A6C00515F55 /* PDFPassword.swift in Sources */ = {isa = PBXBuildFile; fileRef = F758B3DB212C4A6C00515F55 /* PDFPassword.swift */; };
+		F758B3E4212C4A6C00515F55 /* PDFGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F758B3DC212C4A6C00515F55 /* PDFGenerator.swift */; };
+		F758B3E5212C4A6C00515F55 /* FilePathConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = F758B3DD212C4A6C00515F55 /* FilePathConvertible.swift */; };
+		F758B3E6212C4A6C00515F55 /* PDFPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = F758B3DE212C4A6C00515F55 /* PDFPage.swift */; };
+		F758B3E7212C4A6C00515F55 /* PDFGenerateError.swift in Sources */ = {isa = PBXBuildFile; fileRef = F758B3DF212C4A6C00515F55 /* PDFGenerateError.swift */; };
+		F758B407212C4AD300515F55 /* ImageScannerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F758B3E9212C4AD300515F55 /* ImageScannerController.swift */; };
+		F758B408212C4AD300515F55 /* RectangleFeaturesFunnel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F758B3EB212C4AD300515F55 /* RectangleFeaturesFunnel.swift */; };
+		F758B409212C4AD300515F55 /* CaptureSessionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F758B3EC212C4AD300515F55 /* CaptureSessionManager.swift */; };
+		F758B40A212C4AD300515F55 /* ShutterButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = F758B3ED212C4AD300515F55 /* ShutterButton.swift */; };
+		F758B40B212C4AD300515F55 /* CloseButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = F758B3EE212C4AD300515F55 /* CloseButton.swift */; };
+		F758B40C212C4AD300515F55 /* ScannerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F758B3EF212C4AD300515F55 /* ScannerViewController.swift */; };
+		F758B40D212C4AD300515F55 /* AVCaptureVideoOrientation+Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = F758B3F1212C4AD300515F55 /* AVCaptureVideoOrientation+Utils.swift */; };
+		F758B40E212C4AD300515F55 /* CGPoint+Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = F758B3F2212C4AD300515F55 /* CGPoint+Utils.swift */; };
+		F758B40F212C4AD300515F55 /* CGAffineTransform+Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = F758B3F3212C4AD300515F55 /* CGAffineTransform+Utils.swift */; };
+		F758B410212C4AD300515F55 /* CGRect+Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = F758B3F4212C4AD300515F55 /* CGRect+Utils.swift */; };
+		F758B411212C4AD300515F55 /* Array+Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = F758B3F5212C4AD300515F55 /* Array+Utils.swift */; };
+		F758B412212C4AD300515F55 /* UIImage+Orientation.swift in Sources */ = {isa = PBXBuildFile; fileRef = F758B3F6212C4AD300515F55 /* UIImage+Orientation.swift */; };
+		F758B413212C4AD300515F55 /* UIImage+Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = F758B3F7212C4AD300515F55 /* UIImage+Utils.swift */; };
+		F758B414212C4AD300515F55 /* CIRectangleFeature+Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = F758B3F8212C4AD300515F55 /* CIRectangleFeature+Utils.swift */; };
+		F758B415212C4AD300515F55 /* ZoomGestureController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F758B3FA212C4AD300515F55 /* ZoomGestureController.swift */; };
+		F758B416212C4AD300515F55 /* EditScanViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F758B3FB212C4AD300515F55 /* EditScanViewController.swift */; };
+		F758B417212C4AD300515F55 /* EditScanCornerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F758B3FE212C4AD300515F55 /* EditScanCornerView.swift */; };
+		F758B418212C4AD300515F55 /* Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = F758B3FF212C4AD300515F55 /* Error.swift */; };
+		F758B419212C4AD300515F55 /* RectangleDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = F758B400212C4AD300515F55 /* RectangleDetector.swift */; };
+		F758B41A212C4AD300515F55 /* QuadrilateralView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F758B401212C4AD300515F55 /* QuadrilateralView.swift */; };
+		F758B41B212C4AD300515F55 /* Quadrilateral.swift in Sources */ = {isa = PBXBuildFile; fileRef = F758B402212C4AD300515F55 /* Quadrilateral.swift */; };
+		F758B41C212C4AD300515F55 /* ReviewViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F758B404212C4AD300515F55 /* ReviewViewController.swift */; };
+		F758B41D212C4AD300515F55 /* Transformable.swift in Sources */ = {isa = PBXBuildFile; fileRef = F758B406212C4AD300515F55 /* Transformable.swift */; };
 		F75AC2431F1F62450073EC19 /* NCManageAutoUploadFileName.swift in Sources */ = {isa = PBXBuildFile; fileRef = F75AC2421F1F62450073EC19 /* NCManageAutoUploadFileName.swift */; };
 		F75ADF451DC75FFE008A7347 /* CCLogin.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = F75ADF441DC75FFE008A7347 /* CCLogin.storyboard */; };
 		F75AE3C71E9D12900088BB09 /* SwiftyAvatar.swift in Sources */ = {isa = PBXBuildFile; fileRef = F75AE3C61E9D12900088BB09 /* SwiftyAvatar.swift */; };
@@ -972,6 +1002,38 @@
 		F7540F2C1D5B238600C3FFA8 /* x509v3.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = x509v3.h; sourceTree = "<group>"; };
 		F755BD9A20594AC7008C5FBB /* NCService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCService.swift; sourceTree = "<group>"; };
 		F75797AD1E81356C00187A1B /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/CTAssetsPicker.strings; sourceTree = "<group>"; };
+		F758B3D9212C4A6C00515F55 /* PDFPageRenderable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PDFPageRenderable.swift; sourceTree = "<group>"; };
+		F758B3DA212C4A6C00515F55 /* DPIType.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DPIType.swift; sourceTree = "<group>"; };
+		F758B3DB212C4A6C00515F55 /* PDFPassword.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PDFPassword.swift; sourceTree = "<group>"; };
+		F758B3DC212C4A6C00515F55 /* PDFGenerator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PDFGenerator.swift; sourceTree = "<group>"; };
+		F758B3DD212C4A6C00515F55 /* FilePathConvertible.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FilePathConvertible.swift; sourceTree = "<group>"; };
+		F758B3DE212C4A6C00515F55 /* PDFPage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PDFPage.swift; sourceTree = "<group>"; };
+		F758B3DF212C4A6C00515F55 /* PDFGenerateError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PDFGenerateError.swift; sourceTree = "<group>"; };
+		F758B3E0212C4A6C00515F55 /* PDFGenerator.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = PDFGenerator.h; sourceTree = "<group>"; };
+		F758B3E9212C4AD300515F55 /* ImageScannerController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageScannerController.swift; sourceTree = "<group>"; };
+		F758B3EB212C4AD300515F55 /* RectangleFeaturesFunnel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RectangleFeaturesFunnel.swift; sourceTree = "<group>"; };
+		F758B3EC212C4AD300515F55 /* CaptureSessionManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CaptureSessionManager.swift; sourceTree = "<group>"; };
+		F758B3ED212C4AD300515F55 /* ShutterButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ShutterButton.swift; sourceTree = "<group>"; };
+		F758B3EE212C4AD300515F55 /* CloseButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CloseButton.swift; sourceTree = "<group>"; };
+		F758B3EF212C4AD300515F55 /* ScannerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScannerViewController.swift; sourceTree = "<group>"; };
+		F758B3F1212C4AD300515F55 /* AVCaptureVideoOrientation+Utils.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "AVCaptureVideoOrientation+Utils.swift"; sourceTree = "<group>"; };
+		F758B3F2212C4AD300515F55 /* CGPoint+Utils.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "CGPoint+Utils.swift"; sourceTree = "<group>"; };
+		F758B3F3212C4AD300515F55 /* CGAffineTransform+Utils.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "CGAffineTransform+Utils.swift"; sourceTree = "<group>"; };
+		F758B3F4212C4AD300515F55 /* CGRect+Utils.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "CGRect+Utils.swift"; sourceTree = "<group>"; };
+		F758B3F5212C4AD300515F55 /* Array+Utils.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Array+Utils.swift"; sourceTree = "<group>"; };
+		F758B3F6212C4AD300515F55 /* UIImage+Orientation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIImage+Orientation.swift"; sourceTree = "<group>"; };
+		F758B3F7212C4AD300515F55 /* UIImage+Utils.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIImage+Utils.swift"; sourceTree = "<group>"; };
+		F758B3F8212C4AD300515F55 /* CIRectangleFeature+Utils.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "CIRectangleFeature+Utils.swift"; sourceTree = "<group>"; };
+		F758B3FA212C4AD300515F55 /* ZoomGestureController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ZoomGestureController.swift; sourceTree = "<group>"; };
+		F758B3FB212C4AD300515F55 /* EditScanViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EditScanViewController.swift; sourceTree = "<group>"; };
+		F758B3FC212C4AD300515F55 /* WeScan.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = WeScan.h; sourceTree = "<group>"; };
+		F758B3FE212C4AD300515F55 /* EditScanCornerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EditScanCornerView.swift; sourceTree = "<group>"; };
+		F758B3FF212C4AD300515F55 /* Error.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Error.swift; sourceTree = "<group>"; };
+		F758B400212C4AD300515F55 /* RectangleDetector.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RectangleDetector.swift; sourceTree = "<group>"; };
+		F758B401212C4AD300515F55 /* QuadrilateralView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = QuadrilateralView.swift; sourceTree = "<group>"; };
+		F758B402212C4AD300515F55 /* Quadrilateral.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Quadrilateral.swift; sourceTree = "<group>"; };
+		F758B404212C4AD300515F55 /* ReviewViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReviewViewController.swift; sourceTree = "<group>"; };
+		F758B406212C4AD300515F55 /* Transformable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Transformable.swift; sourceTree = "<group>"; };
 		F75AC2421F1F62450073EC19 /* NCManageAutoUploadFileName.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NCManageAutoUploadFileName.swift; sourceTree = "<group>"; };
 		F75ADF441DC75FFE008A7347 /* CCLogin.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = CCLogin.storyboard; sourceTree = "<group>"; };
 		F75AE3C61E9D12900088BB09 /* SwiftyAvatar.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwiftyAvatar.swift; sourceTree = "<group>"; };
@@ -1897,6 +1959,7 @@
 				F7DC5FD31F00F98B00A903C7 /* MGSwipeTableCell */,
 				F7B2DEEB1F976785007CF4D2 /* NYMnemonic */,
 				F7540EE11D5B238600C3FFA8 /* openssl */,
+				F758B3D8212C4A6C00515F55 /* PDFGenerator */,
 				F7CA1EBB20E7E3FE002CC65E /* PKDownloadButton */,
 				F75037421DBFA91A008FB480 /* PureLayout */,
 				F70F05241C889184008DAB36 /* Reachability */,
@@ -2294,6 +2357,100 @@
 			path = openssl;
 			sourceTree = "<group>";
 		};
+		F758B3D8212C4A6C00515F55 /* PDFGenerator */ = {
+			isa = PBXGroup;
+			children = (
+				F758B3D9212C4A6C00515F55 /* PDFPageRenderable.swift */,
+				F758B3DA212C4A6C00515F55 /* DPIType.swift */,
+				F758B3DB212C4A6C00515F55 /* PDFPassword.swift */,
+				F758B3DC212C4A6C00515F55 /* PDFGenerator.swift */,
+				F758B3DD212C4A6C00515F55 /* FilePathConvertible.swift */,
+				F758B3DE212C4A6C00515F55 /* PDFPage.swift */,
+				F758B3DF212C4A6C00515F55 /* PDFGenerateError.swift */,
+				F758B3E0212C4A6C00515F55 /* PDFGenerator.h */,
+			);
+			path = PDFGenerator;
+			sourceTree = "<group>";
+		};
+		F758B3E8212C4AD300515F55 /* WeScan */ = {
+			isa = PBXGroup;
+			children = (
+				F758B3E9212C4AD300515F55 /* ImageScannerController.swift */,
+				F758B3EA212C4AD300515F55 /* Scan */,
+				F758B3F0212C4AD300515F55 /* Extensions */,
+				F758B3F9212C4AD300515F55 /* Edit */,
+				F758B3FC212C4AD300515F55 /* WeScan.h */,
+				F758B3FD212C4AD300515F55 /* Common */,
+				F758B403212C4AD300515F55 /* Review */,
+				F758B405212C4AD300515F55 /* Protocols */,
+			);
+			path = WeScan;
+			sourceTree = "<group>";
+		};
+		F758B3EA212C4AD300515F55 /* Scan */ = {
+			isa = PBXGroup;
+			children = (
+				F758B3EB212C4AD300515F55 /* RectangleFeaturesFunnel.swift */,
+				F758B3EC212C4AD300515F55 /* CaptureSessionManager.swift */,
+				F758B3ED212C4AD300515F55 /* ShutterButton.swift */,
+				F758B3EE212C4AD300515F55 /* CloseButton.swift */,
+				F758B3EF212C4AD300515F55 /* ScannerViewController.swift */,
+			);
+			path = Scan;
+			sourceTree = "<group>";
+		};
+		F758B3F0212C4AD300515F55 /* Extensions */ = {
+			isa = PBXGroup;
+			children = (
+				F758B3F1212C4AD300515F55 /* AVCaptureVideoOrientation+Utils.swift */,
+				F758B3F2212C4AD300515F55 /* CGPoint+Utils.swift */,
+				F758B3F3212C4AD300515F55 /* CGAffineTransform+Utils.swift */,
+				F758B3F4212C4AD300515F55 /* CGRect+Utils.swift */,
+				F758B3F5212C4AD300515F55 /* Array+Utils.swift */,
+				F758B3F6212C4AD300515F55 /* UIImage+Orientation.swift */,
+				F758B3F7212C4AD300515F55 /* UIImage+Utils.swift */,
+				F758B3F8212C4AD300515F55 /* CIRectangleFeature+Utils.swift */,
+			);
+			path = Extensions;
+			sourceTree = "<group>";
+		};
+		F758B3F9212C4AD300515F55 /* Edit */ = {
+			isa = PBXGroup;
+			children = (
+				F758B3FA212C4AD300515F55 /* ZoomGestureController.swift */,
+				F758B3FB212C4AD300515F55 /* EditScanViewController.swift */,
+			);
+			path = Edit;
+			sourceTree = "<group>";
+		};
+		F758B3FD212C4AD300515F55 /* Common */ = {
+			isa = PBXGroup;
+			children = (
+				F758B3FE212C4AD300515F55 /* EditScanCornerView.swift */,
+				F758B3FF212C4AD300515F55 /* Error.swift */,
+				F758B400212C4AD300515F55 /* RectangleDetector.swift */,
+				F758B401212C4AD300515F55 /* QuadrilateralView.swift */,
+				F758B402212C4AD300515F55 /* Quadrilateral.swift */,
+			);
+			path = Common;
+			sourceTree = "<group>";
+		};
+		F758B403212C4AD300515F55 /* Review */ = {
+			isa = PBXGroup;
+			children = (
+				F758B404212C4AD300515F55 /* ReviewViewController.swift */,
+			);
+			path = Review;
+			sourceTree = "<group>";
+		};
+		F758B405212C4AD300515F55 /* Protocols */ = {
+			isa = PBXGroup;
+			children = (
+				F758B406212C4AD300515F55 /* Transformable.swift */,
+			);
+			path = Protocols;
+			sourceTree = "<group>";
+		};
 		F75AE3C51E9D12900088BB09 /* SwiftyAvatar */ = {
 			isa = PBXGroup;
 			children = (
@@ -3067,6 +3224,7 @@
 				F7B1FBAF1E72E3D1001781FE /* SwiftWebVC */,
 				F762CB8B1EACB84400B38484 /* TWMessageBarManager */,
 				F762CB1C1EACB7D400B38484 /* VFR Pdf Reader */,
+				F758B3E8212C4AD300515F55 /* WeScan */,
 				F762CA9F1EACB66200B38484 /* XLForm */,
 			);
 			path = Library;
@@ -3815,6 +3973,7 @@
 				F70022A41EC4C9100080073F /* AFNetworkReachabilityManager.m in Sources */,
 				F762CAFD1EACB66200B38484 /* XLFormInlineSelectorCell.m in Sources */,
 				F77B0DF21D118A16002130FE /* CCUploadFromOtherUpp.m in Sources */,
+				F758B419212C4AD300515F55 /* RectangleDetector.swift in Sources */,
 				F77B0DF41D118A16002130FE /* CCMain.m in Sources */,
 				F7E9C41B20F4CA870040CF18 /* CCTransfers.m in Sources */,
 				F73B4F0D1F470D9100BBEE4B /* nsLatin1Prober.cpp in Sources */,
@@ -3827,7 +3986,9 @@
 				F73B4EEF1F470D9100BBEE4B /* CharDistribution.cpp in Sources */,
 				F7B0C0CD1EE7E7750033AC24 /* CCSynchronize.m in Sources */,
 				F77B0DFF1D118A16002130FE /* OCNetworking.m in Sources */,
+				F758B409212C4AD300515F55 /* CaptureSessionManager.swift in Sources */,
 				F73B4F081F470D9100BBEE4B /* nsEUCJPProber.cpp in Sources */,
+				F758B408212C4AD300515F55 /* RectangleFeaturesFunnel.swift in Sources */,
 				F70022DA1EC4C9100080073F /* OCHTTPRequestOperation.m in Sources */,
 				F7D4245C1F063B82009C9782 /* CTAssetCheckmark.m in Sources */,
 				F70022A11EC4C9100080073F /* AFHTTPSessionManager.m in Sources */,
@@ -3837,7 +3998,9 @@
 				F77B0E041D118A16002130FE /* UIImage+animatedGIF.m in Sources */,
 				F7D423881F0596C6009C9782 /* ReaderThumbView.m in Sources */,
 				F73CCE301DC13798007E38D8 /* UICKeyChainStore.m in Sources */,
+				F758B414212C4AD300515F55 /* CIRectangleFeature+Utils.swift in Sources */,
 				F73B4EFE1F470D9100BBEE4B /* LangHungarianModel.cpp in Sources */,
+				F758B40B212C4AD300515F55 /* CloseButton.swift in Sources */,
 				F7D4238A1F0596C6009C9782 /* ThumbsMainToolbar.m in Sources */,
 				F70022EC1EC4C9100080073F /* OCXMLSharedParser.m in Sources */,
 				F7F54D061E5B14C800E19C62 /* MWCaptionView.m in Sources */,
@@ -3849,6 +4012,7 @@
 				F7DC5FEC1F011EB700A903C7 /* MGSwipeButton.m in Sources */,
 				F7D423801F0596C6009C9782 /* ReaderMainPagebar.m in Sources */,
 				F762CB061EACB66200B38484 /* XLFormTextViewCell.m in Sources */,
+				F758B3E7212C4A6C00515F55 /* PDFGenerateError.swift in Sources */,
 				F762CB881EACB81000B38484 /* REMenuContainerView.m in Sources */,
 				F7D4237F1F0596C6009C9782 /* ReaderDocumentOutline.m in Sources */,
 				F73F537F1E929C8500F8678D /* CCMore.swift in Sources */,
@@ -3856,12 +4020,15 @@
 				F73B4EF71F470D9100BBEE4B /* LangBulgarianModel.cpp in Sources */,
 				F7F54D0C1E5B14C800E19C62 /* MWTapDetectingView.m in Sources */,
 				F7D424631F063B82009C9782 /* CTAssetSelectionLabel.m in Sources */,
+				F758B40D212C4AD300515F55 /* AVCaptureVideoOrientation+Utils.swift in Sources */,
+				F758B3E4212C4A6C00515F55 /* PDFGenerator.swift in Sources */,
 				F7B1FBC61E72E3D1001781FE /* SwiftModalWebVC.swift in Sources */,
 				F7A5541F204EF8AF008468EC /* TOScrollBar.m in Sources */,
 				F7A321651E9E37960069AD1B /* CCActivity.m in Sources */,
 				F762CB0C1EACB66200B38484 /* XLFormSectionDescriptor.m in Sources */,
 				F77B0E131D118A16002130FE /* AppDelegate.m in Sources */,
 				F762CB861EACB81000B38484 /* RECommonFunctions.m in Sources */,
+				F758B40C212C4AD300515F55 /* ScannerViewController.swift in Sources */,
 				F750374F1DBFA91A008FB480 /* NSArray+PureLayout.m in Sources */,
 				F77B0E141D118A16002130FE /* CCError.m in Sources */,
 				F73B4F131F470D9100BBEE4B /* nsUniversalDetector.cpp in Sources */,
@@ -3879,6 +4046,9 @@
 				F762CB111EACB66200B38484 /* NSString+XLFormAdditions.m in Sources */,
 				F762CB9B1EACB84400B38484 /* TWMessageBarManager.m in Sources */,
 				F7D423871F0596C6009C9782 /* ReaderThumbsView.m in Sources */,
+				F758B41A212C4AD300515F55 /* QuadrilateralView.swift in Sources */,
+				F758B416212C4AD300515F55 /* EditScanViewController.swift in Sources */,
+				F758B3E2212C4A6C00515F55 /* DPIType.swift in Sources */,
 				F77B0E201D118A16002130FE /* CCShareUserOC.m in Sources */,
 				F7B1FBCA1E72E3D1001781FE /* SwiftWebVCActivitySafari.swift in Sources */,
 				F7F54D0A1E5B14C800E19C62 /* MWPhotoBrowser.m in Sources */,
@@ -3909,6 +4079,7 @@
 				F7D424601F063B82009C9782 /* CTAssetPlayButton.m in Sources */,
 				F70022E31EC4C9100080073F /* OCXMLParser.m in Sources */,
 				F77B0E311D118A16002130FE /* CCExifGeo.m in Sources */,
+				F758B412212C4AD300515F55 /* UIImage+Orientation.swift in Sources */,
 				F7D4246B1F063B82009C9782 /* CTAssetsPageView.m in Sources */,
 				F73B4F0E1F470D9100BBEE4B /* nsMBCSGroupProber.cpp in Sources */,
 				F78964AE1EBB576C00403E13 /* JDStatusBarStyle.m in Sources */,
@@ -3934,8 +4105,11 @@
 				F7BAADC81ED5A87C00B7EAD4 /* NCDatabase.swift in Sources */,
 				F77B0E541D118A16002130FE /* CCMove.m in Sources */,
 				F7A5541E204EF8AF008468EC /* TOScrollBarGestureRecognizer.m in Sources */,
+				F758B41B212C4AD300515F55 /* Quadrilateral.swift in Sources */,
 				F70022E61EC4C9100080073F /* OCXMLServerErrorsParser.m in Sources */,
+				F758B3E1212C4A6C00515F55 /* PDFPageRenderable.swift in Sources */,
 				F762CB171EACB66200B38484 /* XLFormRegexValidator.m in Sources */,
+				F758B418212C4AD300515F55 /* Error.swift in Sources */,
 				F73CC0691E813DFF006E3047 /* BKPasscodeDummyViewController.m in Sources */,
 				F762CB1A1EACB66200B38484 /* XLForm.m in Sources */,
 				F73B4EFC1F470D9100BBEE4B /* LangGreekModel.cpp in Sources */,
@@ -3944,6 +4118,8 @@
 				F73B4EFB1F470D9100BBEE4B /* LangGermanModel.cpp in Sources */,
 				F73B4F061F470D9100BBEE4B /* nsEscCharsetProber.cpp in Sources */,
 				F7D4237D1F0596C6009C9782 /* ReaderContentView.m in Sources */,
+				F758B417212C4AD300515F55 /* EditScanCornerView.swift in Sources */,
+				F758B40E212C4AD300515F55 /* CGPoint+Utils.swift in Sources */,
 				F73B4EFA1F470D9100BBEE4B /* LangFrenchModel.cpp in Sources */,
 				F7D4245D1F063B82009C9782 /* CTAssetCollectionViewCell.m in Sources */,
 				F7D4245E1F063B82009C9782 /* CTAssetCollectionViewController.m in Sources */,
@@ -3994,6 +4170,7 @@
 				F7CA1ED720E7E3FE002CC65E /* PKDownloadButton.m in Sources */,
 				F72AAECB1E5C60C700BB17E1 /* AHKActionSheetViewController.m in Sources */,
 				F77B0E921D118A16002130FE /* CCCellMainTransfer.m in Sources */,
+				F758B413212C4AD300515F55 /* UIImage+Utils.swift in Sources */,
 				F7659A391DC0B737004860C4 /* iRate.m in Sources */,
 				F7B1FBC81E72E3D1001781FE /* SwiftWebVCActivity.swift in Sources */,
 				F7D424741F063B82009C9782 /* CTAssetThumbnailView.m in Sources */,
@@ -4011,6 +4188,7 @@
 				F70022BF1EC4C9100080073F /* OCFileDto.m in Sources */,
 				F7CA1ED020E7E3FE002CC65E /* UIImage+PKDownloadButton.m in Sources */,
 				F73B4F011F470D9100BBEE4B /* LangThaiModel.cpp in Sources */,
+				F758B40F212C4AD300515F55 /* CGAffineTransform+Utils.swift in Sources */,
 				F70022DD1EC4C9100080073F /* OCWebDAVClient.m in Sources */,
 				F73B4F001F470D9100BBEE4B /* LangSpanishModel.cpp in Sources */,
 				F70022BC1EC4C9100080073F /* OCExternalSites.m in Sources */,
@@ -4018,12 +4196,16 @@
 				F762CB031EACB66200B38484 /* XLFormStepCounterCell.m in Sources */,
 				F762CAF71EACB66200B38484 /* XLFormBaseCell.m in Sources */,
 				F70022E01EC4C9100080073F /* OCXMLListParser.m in Sources */,
+				F758B407212C4AD300515F55 /* ImageScannerController.swift in Sources */,
 				F70022B31EC4C9100080073F /* OCActivity.m in Sources */,
 				F70022D41EC4C9100080073F /* NSDate+ISO8601.m in Sources */,
 				F7D424731F063B82009C9782 /* CTAssetThumbnailStacks.m in Sources */,
 				F78964AD1EBB576C00403E13 /* JDStatusBarNotification.m in Sources */,
+				F758B3E3212C4A6C00515F55 /* PDFPassword.swift in Sources */,
+				F758B415212C4AD300515F55 /* ZoomGestureController.swift in Sources */,
 				F762CB151EACB66200B38484 /* XLFormRowNavigationAccessoryView.m in Sources */,
 				F77B0EB61D118A16002130FE /* MBProgressHUD.m in Sources */,
+				F758B410212C4AD300515F55 /* CGRect+Utils.swift in Sources */,
 				F7D4246D1F063B82009C9782 /* CTAssetsPickerAccessDeniedView.m in Sources */,
 				F762CB0A1EACB66200B38484 /* XLFormDescriptor.m in Sources */,
 				F7D4238C1F0596C6009C9782 /* UIXToolbarView.m in Sources */,
@@ -4042,6 +4224,7 @@
 				F73B4F031F470D9100BBEE4B /* LangVietnameseModel.cpp in Sources */,
 				F73B4F021F470D9100BBEE4B /* LangTurkishModel.cpp in Sources */,
 				F7F54D0B1E5B14C800E19C62 /* MWTapDetectingImageView.m in Sources */,
+				F758B3E5212C4A6C00515F55 /* FilePathConvertible.swift in Sources */,
 				F7D423821F0596C6009C9782 /* ReaderThumbCache.m in Sources */,
 				F70022A71EC4C9100080073F /* AFSecurityPolicy.m in Sources */,
 				F78964AF1EBB576C00403E13 /* JDStatusBarView.m in Sources */,
@@ -4050,6 +4233,7 @@
 				F762CB0B1EACB66200B38484 /* XLFormRowDescriptor.m in Sources */,
 				F7169A1C1EE590930086BD69 /* NCShares.m in Sources */,
 				F77B0EC61D118A16002130FE /* CCCellMain.m in Sources */,
+				F758B3E6212C4A6C00515F55 /* PDFPage.swift in Sources */,
 				F7DC5FED1F011EB700A903C7 /* MGSwipeTableCell.m in Sources */,
 				F7D424541F063B82009C9782 /* NSDateFormatter+CTAssetsPickerController.m in Sources */,
 				F7D4238B1F0596C6009C9782 /* ThumbsViewController.m in Sources */,
@@ -4057,6 +4241,7 @@
 				F7D423811F0596C6009C9782 /* ReaderMainToolbar.m in Sources */,
 				F762CB131EACB66200B38484 /* XLFormRightDetailCell.m in Sources */,
 				F7CA1ED820E7E3FE002CC65E /* PKBorderedButton.m in Sources */,
+				F758B40A212C4AD300515F55 /* ShutterButton.swift in Sources */,
 				F7D4237B1F0596C6009C9782 /* ReaderContentPage.m in Sources */,
 				F73B4F0A1F470D9100BBEE4B /* nsEUCTWProber.cpp in Sources */,
 				F762CB871EACB81000B38484 /* REMenu.m in Sources */,
@@ -4064,9 +4249,12 @@
 				F762CB161EACB66200B38484 /* XLFormTextView.m in Sources */,
 				F7D424681F063B82009C9782 /* CTAssetsGridViewFooter.m in Sources */,
 				F75AC2431F1F62450073EC19 /* NCManageAutoUploadFileName.swift in Sources */,
+				F758B411212C4AD300515F55 /* Array+Utils.swift in Sources */,
+				F758B41C212C4AD300515F55 /* ReviewViewController.swift in Sources */,
 				F7D424701F063B82009C9782 /* CTAssetsPickerNoAssetsView.m in Sources */,
 				F7D424591F063B82009C9782 /* PHImageManager+CTAssetsPickerController.m in Sources */,
 				F7CA1ED620E7E3FE002CC65E /* CALayer+PKDownloadButtonAnimations.m in Sources */,
+				F758B41D212C4AD300515F55 /* Transformable.swift in Sources */,
 				F7ECBA6D1E239DCD003E6328 /* CCCreateCloud.swift in Sources */,
 				F70022FE1EC4C9100080073F /* UtilsFramework.m in Sources */,
 				F7D424561F063B82009C9782 /* NSNumberFormatter+CTAssetsPickerController.m in Sources */,

+ 5 - 0
iOSClient/AppDelegate.m

@@ -94,6 +94,11 @@
     if (![[NSFileManager defaultManager] fileExistsAtPath: path])
         [[NSFileManager defaultManager] createDirectoryAtPath:path withIntermediateDirectories:YES attributes:nil error:nil];
     
+    // create Directory PDFGenerator
+    path = [[dirGroup URLByAppendingPathComponent:k_appPDFGenerator] path];
+    if (![[NSFileManager defaultManager] fileExistsAtPath:path])
+        [[NSFileManager defaultManager] createDirectoryAtPath:path withIntermediateDirectories:YES attributes:nil error:nil];
+    
     // Verify upgrade
     if ([self upgrade]) {
     

+ 1 - 0
iOSClient/CCGlobal.h

@@ -44,6 +44,7 @@
 #define k_appDatabaseNextcloud                          @"Library/Application Support/Nextcloud"
 #define k_appUserData                                   @"Library/Application Support/UserData"
 #define k_appCertificates                               @"Library/Application Support/Certificates"
+#define k_appPDFGenerator                               @"Library/Application Support/PDFGenerator"
 #define k_DirectoryProviderStorage                      @"File Provider Storage"
 
 // Server Status

+ 67 - 0
iOSClient/Library/WeScan/Common/EditScanCornerView.swift

@@ -0,0 +1,67 @@
+//
+//  EditScanCornerView.swift
+//  WeScan
+//
+//  Created by Boris Emorine on 3/5/18.
+//  Copyright © 2018 WeTransfer. All rights reserved.
+//
+
+import UIKit
+
+/// A UIView used by corners of a quadrilateral that is aware of its position.
+final class EditScanCornerView: UIView {
+    
+    let position: CornerPosition
+    
+    /// The image to display when the corner view is highlighted.
+    private var image: UIImage?
+    private(set) var isHighlighted = false
+    
+    lazy private var circleLayer: CAShapeLayer = {
+        let layer = CAShapeLayer()
+        layer.fillColor = UIColor.clear.cgColor
+        layer.strokeColor = UIColor.white.cgColor
+        layer.lineWidth = 1.0
+        return layer
+    }()
+    
+    init(frame: CGRect, position: CornerPosition) {
+        self.position = position
+        super.init(frame: frame)
+        backgroundColor = UIColor.clear
+        clipsToBounds = true
+        layer.addSublayer(circleLayer)
+    }
+    
+    required init?(coder aDecoder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+    
+    override func layoutSubviews() {
+        super.layoutSubviews()
+        layer.cornerRadius = bounds.width / 2.0
+    }
+    
+    override func draw(_ rect: CGRect) {
+        super.draw(rect)
+        
+        let bezierPath = UIBezierPath(ovalIn: rect.insetBy(dx: circleLayer.lineWidth, dy: circleLayer.lineWidth))
+        circleLayer.frame = rect
+        circleLayer.path = bezierPath.cgPath
+        
+        image?.draw(in: rect)
+    }
+    
+    func highlightWithImage(_ image: UIImage) {
+        isHighlighted = true
+        self.image = image
+        self.setNeedsDisplay()
+    }
+    
+    func reset() {
+        isHighlighted = false
+        image = nil
+        setNeedsDisplay()
+    }
+    
+}

+ 38 - 0
iOSClient/Library/WeScan/Common/Error.swift

@@ -0,0 +1,38 @@
+//
+//  Error.swift
+//  WeScan
+//
+//  Created by Boris Emorine on 2/28/18.
+//  Copyright © 2018 WeTransfer. All rights reserved.
+//
+
+import Foundation
+
+/// Errors related to the `ImageScannerController`
+public enum ImageScannerControllerError: Error {
+    /// The user didn't grant permission to use the camera.
+    case authorization
+    /// An error occured when setting up the user's device.
+    case inputDevice
+    /// An error occured when trying to capture a picture.
+    case capture
+    /// Error when creating the CIImage.
+    case ciImageCreation
+}
+
+extension ImageScannerControllerError: LocalizedError {
+    
+    public var errorDescription: String? {
+        switch self {
+        case .authorization:
+            return "Failed to get the user's authorization for camera."
+        case .inputDevice:
+            return "Could not setup input device."
+        case .capture:
+            return "Could not capture pitcure."
+        case .ciImageCreation:
+            return "Internal Error - Could not create CIImage"
+        }
+    }
+
+}

+ 173 - 0
iOSClient/Library/WeScan/Common/Quadrilateral.swift

@@ -0,0 +1,173 @@
+//
+//  Quadrilateral.swift
+//  WeScan
+//
+//  Created by Boris Emorine on 2/8/18.
+//  Copyright © 2018 WeTransfer. All rights reserved.
+//
+
+import Foundation
+import AVFoundation
+
+/// A data structure representing a quadrilateral and its position. This class exists to bypass the fact that CIRectangleFeature is read-only.
+public struct Quadrilateral: Transformable {
+    
+    /// A point that specifies the top left corner of the quadrilateral.
+    var topLeft: CGPoint
+    
+    /// A point that specifies the top right corner of the quadrilateral.
+    var topRight: CGPoint
+    
+    /// A point that specifies the bottom right corner of the quadrilateral.
+    var bottomRight: CGPoint
+
+    /// A point that specifies the bottom left corner of the quadrilateral.
+    var bottomLeft: CGPoint
+        
+    init(rectangleFeature: CIRectangleFeature) {
+        self.topLeft = rectangleFeature.topLeft
+        self.topRight = rectangleFeature.topRight
+        self.bottomLeft = rectangleFeature.bottomLeft
+        self.bottomRight = rectangleFeature.bottomRight
+    }
+    
+    init(topLeft: CGPoint, topRight: CGPoint, bottomRight: CGPoint, bottomLeft: CGPoint) {
+        self.topLeft = topLeft
+        self.topRight = topRight
+        self.bottomRight = bottomRight
+        self.bottomLeft = bottomLeft
+    }
+    
+    /// Generates a `UIBezierPath` of the quadrilateral.
+    func path() -> UIBezierPath {
+        let path = UIBezierPath()
+        path.move(to: topLeft)
+        path.addLine(to: topRight)
+        path.addLine(to: bottomRight)
+        path.addLine(to: bottomLeft)
+        path.close()
+        
+        return path
+    }
+    
+    /// Applies a `CGAffineTransform` to the quadrilateral.
+    ///
+    /// - Parameters:
+    ///   - t: the transform to apply.
+    /// - Returns: The transformed quadrilateral.
+    func applying(_ transform: CGAffineTransform) -> Quadrilateral {
+        let quadrilateral = Quadrilateral(topLeft: topLeft.applying(transform), topRight: topRight.applying(transform), bottomRight: bottomRight.applying(transform), bottomLeft: bottomLeft.applying(transform))
+        
+        return quadrilateral
+    }
+    
+    /// Reorganizes the current quadrilateal, making sure that the points are at their appropriate positions. For example, it ensures that the top left point is actually the top and left point point of the quadrilateral.
+    mutating func reorganize() {
+        let points = [topLeft, topRight, bottomRight, bottomLeft]
+        let ySortedPoints = sortPointsByYValue(points)
+        
+        guard ySortedPoints.count == 4 else {
+            return
+        }
+        
+        let topMostPoints = Array(ySortedPoints[0..<2])
+        let bottomMostPoints = Array(ySortedPoints[2..<4])
+        let xSortedTopMostPoints = sortPointsByXValue(topMostPoints)
+        let xSortedBottomMostPoints = sortPointsByXValue(bottomMostPoints)
+        
+        guard xSortedTopMostPoints.count > 1,
+        xSortedBottomMostPoints.count > 1 else {
+            return
+        }
+        
+        topLeft = xSortedTopMostPoints[0]
+        topRight = xSortedTopMostPoints[1]
+        bottomRight = xSortedBottomMostPoints[1]
+        bottomLeft = xSortedBottomMostPoints[0]
+    }
+    
+    /// Scales the quadrilateral based on the ratio of two given sizes, and optionnaly applies a rotation.
+    ///
+    /// - Parameters:
+    ///   - fromSize: The size the quadrilateral is currently related to.
+    ///   - toSize: The size to scale the quadrilateral to.
+    ///   - rotationAngle: The optional rotation to apply.
+    /// - Returns: The newly scaled and potentially rotated quadrilateral.
+    func scale(_ fromSize: CGSize, _ toSize: CGSize, withRotationAngle rotationAngle: CGFloat = 0.0) -> Quadrilateral {
+        var invertedfromSize = fromSize
+        let rotated = rotationAngle != 0.0
+        
+        if rotated && rotationAngle != CGFloat.pi {
+            invertedfromSize = CGSize(width: fromSize.height, height: fromSize.width)
+        }
+        
+        var transformedQuad = self
+        let invertedFromSizeWidth = invertedfromSize.width == 0 ? .leastNormalMagnitude : invertedfromSize.width
+        
+        let scale = toSize.width / invertedFromSizeWidth
+        let scaledTransform = CGAffineTransform(scaleX: scale, y: scale)
+        transformedQuad = transformedQuad.applying(scaledTransform)
+        
+        if rotated {
+            let rotationTransform = CGAffineTransform(rotationAngle: rotationAngle)
+            
+            let fromImageBounds = CGRect(x: 0.0, y: 0.0, width: fromSize.width, height: fromSize.height).applying(scaledTransform).applying(rotationTransform)
+            
+            let toImageBounds = CGRect(x: 0.0, y: 0.0, width: toSize.width, height: toSize.height)
+            let translationTransform = CGAffineTransform.translateTransform(fromCenterOfRect: fromImageBounds, toCenterOfRect: toImageBounds)
+            
+            transformedQuad = transformedQuad.applyTransforms([rotationTransform, translationTransform])
+        }
+        
+        return transformedQuad
+    }
+    
+    // Convenience functions
+    
+    /// Sorts the given `CGPoints` based on their y value.
+    /// - Parameters:
+    ///   - points: The poinmts to sort.
+    /// - Returns: The points sorted based on their y value.
+    private func sortPointsByYValue(_ points: [CGPoint]) -> [CGPoint] {
+        return points.sorted { (point1, point2) -> Bool in
+            point1.y < point2.y
+        }
+    }
+    
+    /// Sorts the given `CGPoints` based on their x value.
+    /// - Parameters:
+    ///   - points: The poinmts to sort.
+    /// - Returns: The points sorted based on their x value.
+    private func sortPointsByXValue(_ points: [CGPoint]) -> [CGPoint] {
+        return points.sorted { (point1, point2) -> Bool in
+            point1.x < point2.x
+        }
+    }
+
+}
+
+extension Quadrilateral {
+    
+    /// Converts the current to the cartesian coordinate system (where 0 on the y axis is at the bottom).
+    ///
+    /// - Parameters:
+    ///   - height: The height of the rect containing the quadrilateral.
+    /// - Returns: The same quadrilateral in the cartesian corrdinate system.
+    func toCartesian(withHeight height: CGFloat) -> Quadrilateral {
+        let topLeft = self.topLeft.cartesian(withHeight: height)
+        let topRight = self.topRight.cartesian(withHeight: height)
+        let bottomRight = self.bottomRight.cartesian(withHeight: height)
+        let bottomLeft = self.bottomLeft.cartesian(withHeight: height)
+        
+        return Quadrilateral(topLeft: topLeft, topRight: topRight, bottomRight: bottomRight, bottomLeft: bottomLeft)
+    }
+    
+}
+
+extension Quadrilateral: Equatable {
+    
+    public static func == (lhs: Quadrilateral, rhs: Quadrilateral) -> Bool {
+        return lhs.topLeft == rhs.topLeft && lhs.topRight == rhs.topRight && lhs.bottomRight == rhs.bottomRight && lhs.bottomLeft == rhs.bottomLeft
+    }
+    
+}

+ 301 - 0
iOSClient/Library/WeScan/Common/QuadrilateralView.swift

@@ -0,0 +1,301 @@
+//
+//  RectangleView.swift
+//  WeScan
+//
+//  Created by Boris Emorine on 2/8/18.
+//  Copyright © 2018 WeTransfer. All rights reserved.
+//
+
+import UIKit
+import AVFoundation
+
+/// Simple enum to keep track of the position of the corners of a quadrilateral.
+enum CornerPosition {
+    case topLeft
+    case topRight
+    case bottomRight
+    case bottomLeft
+}
+
+/// The `QuadrilateralView` is a simple `UIView` subclass that can draw a quadrilateral, and optionally edit it.
+final class QuadrilateralView: UIView {
+    
+    private let quadLayer: CAShapeLayer = {
+        let layer = CAShapeLayer()
+        layer.strokeColor = UIColor.white.cgColor
+        layer.lineWidth = 1.0
+        layer.opacity = 1.0
+        layer.isHidden = true
+        
+        return layer
+    }()
+    
+    /// We want the corner views to be displayed under the outline of the quadrilateral.
+    /// Because of that, we need the quadrilateral to be drawn on a UIView above them.
+    private let quadView: UIView = {
+        let view = UIView()
+        view.backgroundColor = UIColor.clear
+        view.translatesAutoresizingMaskIntoConstraints = false
+        return view
+    }()
+    
+    /// The quadrilateral drawn on the view.
+    private(set) var quad: Quadrilateral?
+    
+    public var editable = false {
+        didSet {
+            editable == true ? showCornerViews() : hideCornerViews()
+            quadLayer.fillColor = editable ? UIColor(white: 0.0, alpha: 0.6).cgColor : UIColor(white: 1.0, alpha: 0.5).cgColor
+            guard let quad = quad else {
+                return
+            }
+            drawQuad(quad, animated: false)
+            layoutCornerViews(forQuad: quad)
+        }
+    }
+    
+    private var isHighlighted = false {
+        didSet (oldValue) {
+            guard oldValue != isHighlighted else {
+                return
+            }
+            quadLayer.fillColor = isHighlighted ? UIColor.clear.cgColor : UIColor(white: 0.0, alpha: 0.6).cgColor
+            isHighlighted ? bringSubview(toFront: quadView) : sendSubview(toBack: quadView)
+        }
+    }
+    
+    lazy private var topLeftCornerView: EditScanCornerView = {
+        return EditScanCornerView(frame: CGRect(x: 0.0, y: 0.0, width: cornerViewSize, height: cornerViewSize), position: .topLeft)
+    }()
+    
+    lazy private var topRightCornerView: EditScanCornerView = {
+        return EditScanCornerView(frame: CGRect(x: 0.0, y: 0.0, width: cornerViewSize, height: cornerViewSize), position: .topRight)
+    }()
+    
+    lazy private var bottomRightCornerView: EditScanCornerView = {
+        return EditScanCornerView(frame: CGRect(x: 0.0, y: 0.0, width: cornerViewSize, height: cornerViewSize), position: .bottomRight)
+    }()
+    
+    lazy private var bottomLeftCornerView: EditScanCornerView = {
+        return EditScanCornerView(frame: CGRect(x: 0.0, y: 0.0, width: cornerViewSize, height: cornerViewSize), position: .bottomLeft)
+    }()
+    
+    private let highlightedCornerViewSize: CGFloat = 75.0
+    private let cornerViewSize: CGFloat = 20.0
+    
+    // MARK: - Life Cycle
+    
+    override init(frame: CGRect) {
+        super.init(frame: frame)
+        commonInit()
+    }
+    
+    required public init?(coder aDecoder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+    
+    private func commonInit() {
+        addSubview(quadView)
+        setupCornerViews()
+        setupConstraints()
+        quadView.layer.addSublayer(quadLayer)
+    }
+    
+    private func setupConstraints() {
+        let quadViewConstraints = [
+            quadView.topAnchor.constraint(equalTo: topAnchor),
+            quadView.leadingAnchor.constraint(equalTo: leadingAnchor),
+            bottomAnchor.constraint(equalTo: quadView.bottomAnchor),
+            trailingAnchor.constraint(equalTo: quadView.trailingAnchor)
+        ]
+        
+        NSLayoutConstraint.activate(quadViewConstraints)
+    }
+    
+    private func setupCornerViews() {
+        addSubview(topLeftCornerView)
+        addSubview(topRightCornerView)
+        addSubview(bottomRightCornerView)
+        addSubview(bottomLeftCornerView)
+    }
+    
+    override public func layoutSubviews() {
+        super.layoutSubviews()
+        guard quadLayer.frame != bounds else {
+            return
+        }
+        
+        quadLayer.frame = bounds
+        if let quad = quad {
+            drawQuadrilateral(quad: quad, animated: false)
+        }
+    }
+    
+    // MARK: - Drawings
+    
+    /// Draws the passed in quadrilateral.
+    ///
+    /// - Parameters:
+    ///   - quad: The quadrilateral to draw on the view. It should be in the coordinates of the current `QuadrilateralView` instance.
+    func drawQuadrilateral(quad: Quadrilateral, animated: Bool) {
+        self.quad = quad
+        drawQuad(quad, animated: animated)
+        if editable {
+            showCornerViews()
+            layoutCornerViews(forQuad: quad)
+        }
+    }
+    
+    private func drawQuad(_ quad: Quadrilateral, animated: Bool) {
+        var path = quad.path()
+        
+        if editable {
+            path = path.reversing()
+            let rectPath = UIBezierPath(rect: bounds)
+            path.append(rectPath)
+        }
+        
+        if animated == true {
+            let pathAnimation = CABasicAnimation(keyPath: "path")
+            pathAnimation.duration = 0.2
+            quadLayer.add(pathAnimation, forKey: "path")
+        }
+        
+        quadLayer.path = path.cgPath
+        quadLayer.isHidden = false
+    }
+    
+    private func layoutCornerViews(forQuad quad: Quadrilateral) {
+        topLeftCornerView.center = quad.topLeft
+        topRightCornerView.center = quad.topRight
+        bottomLeftCornerView.center = quad.bottomLeft
+        bottomRightCornerView.center = quad.bottomRight
+    }
+    
+    func removeQuadrilateral() {
+        quadLayer.path = nil
+        quadLayer.isHidden = true
+    }
+    
+    // MARK: - Actions
+    
+    func moveCorner(cornerView: EditScanCornerView, atPoint point: CGPoint) {
+        guard let quad = quad else {
+            return
+        }
+        
+        let validPoint = self.validPoint(point, forCornerViewOfSize: cornerView.bounds.size, inView: self)
+        
+        cornerView.center = validPoint
+        let updatedQuad = update(quad, withPosition: validPoint, forCorner: cornerView.position)
+        
+        self.quad = updatedQuad
+        drawQuad(updatedQuad, animated: false)
+    }
+    
+    func highlightCornerAtPosition(position: CornerPosition, with image: UIImage) {
+        guard editable else {
+            return
+        }
+        isHighlighted = true
+        
+        let cornerView = cornerViewForCornerPosition(position: position)
+        guard cornerView.isHighlighted == false else {
+            cornerView.highlightWithImage(image)
+            return
+        }
+        
+        cornerView.frame = CGRect(x: cornerView.frame.origin.x - (highlightedCornerViewSize - cornerViewSize) / 2.0, y: cornerView.frame.origin.y - (highlightedCornerViewSize - cornerViewSize) / 2.0, width: highlightedCornerViewSize, height: highlightedCornerViewSize)
+        cornerView.highlightWithImage(image)
+    }
+    
+    func resetHighlightedCornerViews() {
+        isHighlighted = false
+        resetHighlightedCornerViews(cornerViews: [topLeftCornerView, topRightCornerView, bottomLeftCornerView, bottomRightCornerView])
+    }
+    
+    private func resetHighlightedCornerViews(cornerViews: [EditScanCornerView]) {
+        cornerViews.forEach { (cornerView) in
+            resetHightlightedCornerView(cornerView: cornerView)
+        }
+    }
+    
+    private func resetHightlightedCornerView(cornerView: EditScanCornerView) {
+        cornerView.reset()
+        cornerView.frame = CGRect(x: cornerView.frame.origin.x + (cornerView.frame.size.width - cornerViewSize) / 2.0, y: cornerView.frame.origin.y + (cornerView.frame.size.width - cornerViewSize) / 2.0, width: cornerViewSize, height: cornerViewSize)
+        cornerView.setNeedsDisplay()
+    }
+    
+    // MARK: Validation
+    
+    /// Ensures that the given point is valid - meaning that it is within the bounds of the passed in `UIView`.
+    ///
+    /// - Parameters:
+    ///   - point: The point that needs to be validated.
+    ///   - cornerViewSize: The size of the corner view representing the given point.
+    ///   - view: The view which should include the point.
+    /// - Returns: A new point which is within the passed in view.
+    private func validPoint(_ point: CGPoint, forCornerViewOfSize cornerViewSize: CGSize, inView view: UIView) -> CGPoint {
+        var validPoint = point
+        
+        if point.x > view.bounds.width {
+            validPoint.x = view.bounds.width
+        } else if point.x < 0.0 {
+            validPoint.x = 0.0
+        }
+        
+        if point.y > view.bounds.height {
+            validPoint.y = view.bounds.height
+        } else if point.y < 0.0 {
+            validPoint.y = 0.0
+        }
+        
+        return validPoint
+    }
+    
+    // MARK: - Convenience
+    
+    private func hideCornerViews() {
+        topLeftCornerView.isHidden = true
+        topRightCornerView.isHidden = true
+        bottomRightCornerView.isHidden = true
+        bottomLeftCornerView.isHidden = true
+    }
+    
+    private func showCornerViews() {
+        topLeftCornerView.isHidden = false
+        topRightCornerView.isHidden = false
+        bottomRightCornerView.isHidden = false
+        bottomLeftCornerView.isHidden = false
+    }
+    
+    private func update(_ quad: Quadrilateral, withPosition position: CGPoint, forCorner corner: CornerPosition) -> Quadrilateral {
+        var quad = quad
+        
+        switch corner {
+        case .topLeft:
+            quad.topLeft = position
+        case .topRight:
+            quad.topRight = position
+        case .bottomRight:
+            quad.bottomRight = position
+        case .bottomLeft:
+            quad.bottomLeft = position
+        }
+        
+        return quad
+    }
+    
+    func cornerViewForCornerPosition(position: CornerPosition) -> EditScanCornerView {
+        switch position {
+        case .topLeft:
+            return topLeftCornerView
+        case .topRight:
+            return topRightCornerView
+        case .bottomLeft:
+            return bottomLeftCornerView
+        case .bottomRight:
+            return bottomRightCornerView
+        }
+    }
+}

+ 34 - 0
iOSClient/Library/WeScan/Common/RectangleDetector.swift

@@ -0,0 +1,34 @@
+//
+//  RectangleDetector.swift
+//  WeScan
+//
+//  Created by Boris Emorine on 2/13/18.
+//  Copyright © 2018 WeTransfer. All rights reserved.
+//
+
+import Foundation
+import AVFoundation
+
+/// Class used to detect rectangles from an image.
+struct RectangleDetector {
+    
+    /// Detects rectangles from the given image.
+    ///
+    /// - Parameters:
+    ///   - image: The image to detect rectangles on.
+    /// - Returns: The biggest detected rectangle on the image.
+    static func rectangle(forImage image: CIImage) -> CIRectangleFeature? {
+        let rectangleDetector = CIDetector(ofType: CIDetectorTypeRectangle, context: CIContext(options: nil), options: [CIDetectorAccuracy: CIDetectorAccuracyHigh])
+        
+        guard let rectangleFeatures = rectangleDetector?.features(in: image) as? [CIRectangleFeature] else {
+            return nil
+        }
+        
+        guard let biggestRectangle = rectangleFeatures.biggest() else {
+            return nil
+        }
+        
+        return biggestRectangle
+    }
+    
+}

+ 193 - 0
iOSClient/Library/WeScan/Edit/EditScanViewController.swift

@@ -0,0 +1,193 @@
+//
+//  EditScanViewController.swift
+//  WeScan
+//
+//  Created by Boris Emorine on 2/12/18.
+//  Copyright © 2018 WeTransfer. All rights reserved.
+//
+
+import UIKit
+import AVFoundation
+
+@available(iOS 10, *)
+
+/// The `EditScanViewController` offers an interface for the user to edit the detected quadrilateral.
+final class EditScanViewController: UIViewController {
+    
+    lazy private var imageView: UIImageView = {
+        let imageView = UIImageView()
+        imageView.clipsToBounds = true
+        imageView.isOpaque = true
+        imageView.image = image
+        imageView.backgroundColor = .black
+        imageView.contentMode = .scaleAspectFit
+        imageView.translatesAutoresizingMaskIntoConstraints = false
+        return imageView
+    }()
+    
+    lazy private var quadView: QuadrilateralView = {
+        let quadView = QuadrilateralView()
+        quadView.editable = true
+        quadView.translatesAutoresizingMaskIntoConstraints = false
+        return quadView
+    }()
+    
+    lazy private var nextButton: UIBarButtonItem = {
+        let title = NSLocalizedString("wescan.edit.button.next", comment: "A generic next button")
+        let button = UIBarButtonItem(title: title, style: .plain, target: self, action: #selector(pushReviewController))
+        button.tintColor = navigationController?.navigationBar.tintColor
+        return button
+    }()
+
+    /// The image the quadrilateral was detected on.
+    private let image: UIImage
+    
+    /// The detected quadrilateral that can be edited by the user. Uses the image's coordinates.
+    private var quad: Quadrilateral
+    
+    private var zoomGestureController: ZoomGestureController!
+    
+    private var quadViewWidthConstraint = NSLayoutConstraint()
+    private var quadViewHeightConstraint = NSLayoutConstraint()
+    
+    // MARK: - Life Cycle
+    
+    init(image: UIImage, quad: Quadrilateral?) {
+        self.image = image.applyingPortraitOrientation()
+        self.quad = quad ?? EditScanViewController.defaultQuad(forImage: image)
+        super.init(nibName: nil, bundle: nil)
+    }
+    
+    required init?(coder aDecoder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+    
+    override func viewDidLoad() {
+        super.viewDidLoad()
+        
+        setupViews()
+        setupConstraints()
+        title = NSLocalizedString("wescan.edit.title", comment: "The title of the EditScanViewController")
+        navigationItem.rightBarButtonItem = nextButton
+        
+        zoomGestureController = ZoomGestureController(image: image, quadView: quadView)
+        
+        let touchDown = UILongPressGestureRecognizer(target:zoomGestureController, action: #selector(zoomGestureController.handle(pan:)))
+        touchDown.minimumPressDuration = 0
+        view.addGestureRecognizer(touchDown)
+    }
+    
+    override func viewDidLayoutSubviews() {
+        super.viewDidLayoutSubviews()
+        adjustQuadViewConstraints()
+        displayQuad()
+    }
+    
+    override func viewWillDisappear(_ animated: Bool) {
+        super.viewWillDisappear(animated)
+        
+        // Work around for an iOS 11.2 bug where UIBarButtonItems don't get back to their normal state after being pressed.
+        navigationController?.navigationBar.tintAdjustmentMode = .normal
+        navigationController?.navigationBar.tintAdjustmentMode = .automatic
+    }
+    
+    // MARK: - Setups
+    
+    private func setupViews() {
+        view.addSubview(imageView)
+        view.addSubview(quadView)
+    }
+    
+    private func setupConstraints() {
+        let imageViewConstraints = [
+            imageView.topAnchor.constraint(equalTo: view.topAnchor),
+            imageView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
+            view.bottomAnchor.constraint(equalTo: imageView.bottomAnchor),
+            view.leadingAnchor.constraint(equalTo: imageView.leadingAnchor)
+        ]
+
+        quadViewWidthConstraint = quadView.widthAnchor.constraint(equalToConstant: 0.0)
+        quadViewHeightConstraint = quadView.heightAnchor.constraint(equalToConstant: 0.0)
+        
+        let quadViewConstraints = [
+            quadView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
+            quadView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
+            quadViewWidthConstraint,
+            quadViewHeightConstraint
+        ]
+        
+        NSLayoutConstraint.activate(quadViewConstraints + imageViewConstraints)
+    }
+    
+    // MARK: - Actions
+    
+    @objc func pushReviewController() {
+        guard let quad = quadView.quad,
+            let ciImage = CIImage(image: image) else {
+                if let imageScannerController = navigationController as? ImageScannerController {
+                    let error = ImageScannerControllerError.ciImageCreation
+                    imageScannerController.imageScannerDelegate?.imageScannerController(imageScannerController, didFailWithError: error)
+                }
+            return
+        }
+        
+        let scaledQuad = quad.scale(quadView.bounds.size, image.size)
+        self.quad = scaledQuad
+        
+        var cartesianScaledQuad = scaledQuad.toCartesian(withHeight: image.size.height)
+        cartesianScaledQuad.reorganize()
+        
+        let filteredImage = ciImage.applyingFilter("CIPerspectiveCorrection", parameters: [
+            "inputTopLeft": CIVector(cgPoint: cartesianScaledQuad.bottomLeft),
+            "inputTopRight": CIVector(cgPoint: cartesianScaledQuad.bottomRight),
+            "inputBottomLeft": CIVector(cgPoint: cartesianScaledQuad.topLeft),
+            "inputBottomRight": CIVector(cgPoint: cartesianScaledQuad.topRight)
+            ])
+        
+        var uiImage: UIImage!
+        
+        // Let's try to generate the CGImage from the CIImage before creating a UIImage.
+        if let cgImage = CIContext(options: nil).createCGImage(filteredImage, from: filteredImage.extent) {
+            uiImage = UIImage(cgImage: cgImage)
+        } else {
+            uiImage = UIImage(ciImage: filteredImage, scale: 1.0, orientation: .up)
+        }
+        
+        let results = ImageScannerResults(originalImage: image, scannedImage: uiImage, detectedRectangle: scaledQuad)
+        let reviewViewController = ReviewViewController(results: results)
+        
+        navigationController?.pushViewController(reviewViewController, animated: true)
+    }
+
+    private func displayQuad() {
+        let imageSize = image.size
+        let imageFrame = CGRect(x: quadView.frame.origin.x, y: quadView.frame.origin.y, width: quadViewWidthConstraint.constant, height: quadViewHeightConstraint.constant)
+        
+        let scaleTransform = CGAffineTransform.scaleTransform(forSize: imageSize, aspectFillInSize: imageFrame.size)
+        let transforms = [scaleTransform]
+        let transformedQuad = quad.applyTransforms(transforms)
+        
+        quadView.drawQuadrilateral(quad: transformedQuad, animated: false)
+    }
+    
+    /// The quadView should be lined up on top of the actual image displayed by the imageView.
+    /// Since there is no way to know the size of that image before run time, we adjust the constraints to make sure that the quadView is on top of the displayed image.
+    private func adjustQuadViewConstraints() {
+        let frame = AVMakeRect(aspectRatio: image.size, insideRect: imageView.bounds)
+        quadViewWidthConstraint.constant = frame.size.width
+        quadViewHeightConstraint.constant = frame.size.height
+    }
+    
+    /// Generates a `Quadrilateral` object that's centered and one third of the size of the passed in image.
+    private static func defaultQuad(forImage image: UIImage) -> Quadrilateral {
+        let topLeft = CGPoint(x: image.size.width / 3.0, y: image.size.height / 3.0)
+        let topRight = CGPoint(x: 2.0 * image.size.width / 3.0, y: image.size.height / 3.0)
+        let bottomRight = CGPoint(x: 2.0 * image.size.width / 3.0, y: 2.0 * image.size.height / 3.0)
+        let bottomLeft = CGPoint(x: image.size.width / 3.0, y: 2.0 * image.size.height / 3.0)
+        
+        let quad = Quadrilateral(topLeft: topLeft, topRight: topRight, bottomRight: bottomRight, bottomLeft: bottomLeft)
+        
+        return quad
+    }
+
+}

+ 60 - 0
iOSClient/Library/WeScan/Edit/ZoomGestureController.swift

@@ -0,0 +1,60 @@
+//
+//  ZoomGestureController.swift
+//  WeScan
+//
+//  Created by Bobo on 5/31/18.
+//  Copyright © 2018 WeTransfer. All rights reserved.
+//
+
+import Foundation
+import AVFoundation
+
+final class ZoomGestureController {
+    
+    private let image: UIImage
+    private let quadView: QuadrilateralView
+    
+    init(image: UIImage, quadView: QuadrilateralView) {
+        self.image = image
+        self.quadView = quadView
+    }
+    
+    private var previousPanPosition: CGPoint?
+    private var closestCorner: CornerPosition?
+    
+    @objc func handle(pan: UIGestureRecognizer) {
+        guard let drawnQuad = quadView.quad else {
+            return
+        }
+        
+        guard pan.state != .ended else {
+            self.previousPanPosition = nil
+            self.closestCorner = nil
+            quadView.resetHighlightedCornerViews()
+            return
+        }
+        
+        let position = pan.location(in: quadView)
+        
+        let previousPanPosition = self.previousPanPosition ?? position
+        let closestCorner = self.closestCorner ?? position.closestCornerFrom(quad: drawnQuad)
+        
+        let offset = CGAffineTransform(translationX: position.x - previousPanPosition.x, y: position.y - previousPanPosition.y)
+        let cornerView = quadView.cornerViewForCornerPosition(position: closestCorner)
+        let draggedCornerViewCenter = cornerView.center.applying(offset)
+
+        quadView.moveCorner(cornerView: cornerView, atPoint: draggedCornerViewCenter)
+        
+        self.previousPanPosition = position
+        self.closestCorner = closestCorner
+        
+        let scale = image.size.width / quadView.bounds.size.width
+        let scaledDraggedCornerViewCenter = CGPoint(x: draggedCornerViewCenter.x * scale, y: draggedCornerViewCenter.y * scale)
+        guard let zoomedImage = image.scaledImage(atPoint: scaledDraggedCornerViewCenter, scaleFactor: 2.5, targetSize: quadView.bounds.size) else {
+            return
+        }
+        
+        quadView.highlightCornerAtPosition(position: closestCorner, with: zoomedImage)
+    }
+
+}

+ 34 - 0
iOSClient/Library/WeScan/Extensions/AVCaptureVideoOrientation+Utils.swift

@@ -0,0 +1,34 @@
+//
+//  UIDeviceOrientation+Utils.swift
+//  WeScan
+//
+//  Created by Boris Emorine on 2/13/18.
+//  Copyright © 2018 WeTransfer. All rights reserved.
+//
+
+import Foundation
+import AVFoundation
+
+extension AVCaptureVideoOrientation {
+    
+    /// Maps UIDeviceOrientation to AVCaptureVideoOrientation
+    init?(deviceOrientation: UIDeviceOrientation) {
+        switch deviceOrientation {
+        case .portrait:
+            self.init(rawValue: AVCaptureVideoOrientation.portrait.rawValue)
+        case .portraitUpsideDown:
+            self.init(rawValue: AVCaptureVideoOrientation.portraitUpsideDown.rawValue)
+        case .landscapeLeft:
+            self.init(rawValue: AVCaptureVideoOrientation.landscapeLeft.rawValue)
+        case .landscapeRight:
+            self.init(rawValue: AVCaptureVideoOrientation.landscapeRight.rawValue)
+        case .faceUp:
+            self.init(rawValue: AVCaptureVideoOrientation.portrait.rawValue)
+        case .faceDown:
+            self.init(rawValue: AVCaptureVideoOrientation.portraitUpsideDown.rawValue)
+        default:
+            self.init(rawValue: AVCaptureVideoOrientation.portrait.rawValue)
+        }
+    }
+    
+}

+ 26 - 0
iOSClient/Library/WeScan/Extensions/Array+Utils.swift

@@ -0,0 +1,26 @@
+//
+//  Array+Utils.swift
+//  WeScan
+//
+//  Created by Boris Emorine on 2/8/18.
+//  Copyright © 2018 WeTransfer. All rights reserved.
+//
+
+import Foundation
+
+extension Array where Element: CIRectangleFeature {
+    
+    /// Finds the biggest rectangle within an array of `CIRectangleFeature` objects.
+    func biggest() -> CIRectangleFeature? {
+        guard count > 1 else {
+            return first
+        }
+        
+        let biggestRectangle = self.max(by: { (rect1, rect2) -> Bool in
+            return rect1.perimeter() < rect2.perimeter()
+        })
+        
+        return biggestRectangle
+    }
+    
+}

+ 35 - 0
iOSClient/Library/WeScan/Extensions/CGAffineTransform+Utils.swift

@@ -0,0 +1,35 @@
+//
+//  CGAffineTransform+Utils.swift
+//  WeScan
+//
+//  Created by Boris Emorine on 2/15/18.
+//  Copyright © 2018 WeTransfer. All rights reserved.
+//
+
+import Foundation
+
+extension CGAffineTransform {
+    
+    /// Convenience function to easily get a scale `CGAffineTransform` instance.
+    ///
+    /// - Parameters:
+    ///   - fromSize: The size that needs to be transformed to fit (aspect fill) in the other given size.
+    ///   - toSize: The size that should be matched by the `fromSize` parameter.
+    /// - Returns: The transform that will make the `fromSize` parameter fir (aspect fill) inside the `toSize` parameter.
+    static func scaleTransform(forSize fromSize: CGSize, aspectFillInSize toSize: CGSize) -> CGAffineTransform {
+        let scale = max(toSize.width / fromSize.width, toSize.height / fromSize.height)
+        return CGAffineTransform(scaleX: scale, y: scale)
+    }
+    
+    /// Convenience function to easily get a translate `CGAffineTransform` instance.
+    ///
+    /// - Parameters:
+    ///   - fromRect: The rect which center needs to be translated to the center of the other passed in rect.
+    ///   - toRect: The rect that should be matched.
+    /// - Returns: The transform that will translate the center of the `fromRect` parameter to the center of the `toRect` parameter.
+    static func translateTransform(fromCenterOfRect fromRect: CGRect, toCenterOfRect toRect: CGRect) -> CGAffineTransform {
+        let translate = CGPoint(x: toRect.midX - fromRect.midX, y: toRect.midY - fromRect.midY)
+        return CGAffineTransform(translationX: translate.x, y: translate.y)
+    }
+        
+}

+ 69 - 0
iOSClient/Library/WeScan/Extensions/CGPoint+Utils.swift

@@ -0,0 +1,69 @@
+//
+//  CGPoint+Utils.swift
+//  WeScan
+//
+//  Created by Boris Emorine on 2/9/18.
+//  Copyright © 2018 WeTransfer. All rights reserved.
+//
+
+import Foundation
+
+extension CGPoint {
+    
+    /// Returns a rectangle of a given size surounding the point.
+    ///
+    /// - Parameters:
+    ///   - size: The size of the rectangle that should surround the points.
+    /// - Returns: A `CGRect` instance that surrounds this instance of `CGpoint`.
+    func surroundingSquare(withSize size: CGFloat) -> CGRect {
+        return CGRect(x: x - size / 2.0, y: y - size / 2.0, width: size, height: size)
+    }
+    
+    /// Checks wether this point is within a given distance of another point.
+    ///
+    /// - Parameters:
+    ///   - delta: The minimum distance to meet for this distance to return true.
+    ///   - point: The second point to compare this instance with.
+    /// - Returns: True if the given `CGPoint` is within the given distance of this instance of `CGPoint`.
+    func isWithin(delta: CGFloat, ofPoint point: CGPoint) -> Bool {
+        return (fabs(x - point.x) <= delta) && (fabs(y - point.y) <= delta)
+    }
+    
+    /// Returns the same `CGPoint` in the cartesian coordinate system.
+    ///
+    /// - Parameters:
+    ///   - height: The height of the bounds this points belong to, in the current coordinate system.
+    /// - Returns: The same point in the cartesian coordinate system.
+    func cartesian(withHeight height: CGFloat) -> CGPoint {
+        return CGPoint(x: x, y: height - y)
+    }
+    
+    /// Returns the distance between two points
+    func distanceTo(point: CGPoint) -> CGFloat {
+        return hypot((self.x - point.x), (self.y - point.y))
+    }
+    
+    /// Returns the closest corner from the point
+    func closestCornerFrom(quad: Quadrilateral) -> CornerPosition {
+        var smallestDistance = distanceTo(point: quad.topLeft)
+        var closestCorner = CornerPosition.topLeft
+        
+        if distanceTo(point: quad.topRight) < smallestDistance {
+            smallestDistance = distanceTo(point: quad.topRight)
+            closestCorner = .topRight
+        }
+        
+        if distanceTo(point: quad.bottomRight) < smallestDistance {
+            smallestDistance = distanceTo(point: quad.bottomRight)
+            closestCorner = .bottomRight
+        }
+        
+        if distanceTo(point: quad.bottomLeft) < smallestDistance {
+            smallestDistance = distanceTo(point: quad.bottomLeft)
+            closestCorner = .bottomLeft
+        }
+        
+        return closestCorner
+    }
+    
+}

+ 27 - 0
iOSClient/Library/WeScan/Extensions/CGRect+Utils.swift

@@ -0,0 +1,27 @@
+//
+//  CGRect+Utils.swift
+//  WeScan
+//
+//  Created by Boris Emorine on 2/26/18.
+//  Copyright © 2018 WeTransfer. All rights reserved.
+//
+
+import Foundation
+
+extension CGRect {
+    
+    /// Returns a new `CGRect` instance scaled up or down, with the same center as the original `CGRect` instance.
+    /// - Parameters:
+    ///   - ratio: The ratio to scale the `CGRect` instance by.
+    /// - Returns: A new instance of `CGRect` scaled by the given ratio and centered with the original rect.
+    func scaleAndCenter(withRatio ratio: CGFloat) -> CGRect {
+        let scaleTransform = CGAffineTransform(scaleX: ratio, y: ratio)
+        let scaledRect = applying(scaleTransform)
+        
+        let translateTransform = CGAffineTransform(translationX: origin.x * (1 - ratio) + (width - scaledRect.width) / 2.0, y: origin.y * (1 - ratio) + (height - scaledRect.height) / 2.0)
+        let translatedRect = scaledRect.applying(translateTransform)
+        
+        return translatedRect
+    }
+    
+}

+ 54 - 0
iOSClient/Library/WeScan/Extensions/CIRectangleFeature+Utils.swift

@@ -0,0 +1,54 @@
+//
+//  CIRectangleFeature+Utils.swift
+//  WeScan
+//
+//  Created by Boris Emorine on 2/8/18.
+//  Copyright © 2018 WeTransfer. All rights reserved.
+//
+
+import Foundation
+import AVFoundation
+
+extension CIRectangleFeature {
+    
+    /// The perimeter of the quadrilateral.
+    func perimeter() -> CGFloat {
+        return (topRight.x - topLeft.x) + (topRight.y - bottomRight.y) + (bottomRight.x - bottomLeft.x) + (topLeft.y - bottomLeft.y)
+    }
+    
+    /// Checks whether the quadrilateral is withing a given distance of another quadrilateral.
+    ///
+    /// - Parameters:
+    ///   - distance: The distance (threshold) to use for the condition to be met.
+    ///   - rectangleFeature: The other rectangle to compare this instance with.
+    /// - Returns: True if the given rectangle is within the given distance of this rectangle instance.
+    func isWithin(_ distance: CGFloat, ofRectangleFeature rectangleFeature: CIRectangleFeature) -> Bool {
+        
+        let topLeftRect = topLeft.surroundingSquare(withSize: distance)
+        if !topLeftRect.contains(rectangleFeature.topLeft) {
+            return false
+        }
+        
+        let topRightRect = topRight.surroundingSquare(withSize: distance)
+        if !topRightRect.contains(rectangleFeature.topRight) {
+            return false
+        }
+        
+        let bottomRightRect = bottomRight.surroundingSquare(withSize: distance)
+        if !bottomRightRect.contains(rectangleFeature.bottomRight) {
+            return false
+        }
+
+        let bottomLeftRect = bottomLeft.surroundingSquare(withSize: distance)
+        if !bottomLeftRect.contains(rectangleFeature.bottomLeft) {
+            return false
+        }
+        
+        return true
+    }
+    
+    override open var description: String {
+        return "topLeft: \(topLeft), topRight: \(topRight), bottomRight: \(bottomRight), bottomLeft: \(bottomLeft)"
+    }
+    
+}

+ 74 - 0
iOSClient/Library/WeScan/Extensions/UIImage+Orientation.swift

@@ -0,0 +1,74 @@
+//
+//  UIImage+Orientation.swift
+//  WeScan
+//
+//  Created by Boris Emorine on 2/16/18.
+//  Copyright © 2018 WeTransfer. All rights reserved.
+//
+
+import Foundation
+
+@available(iOS 10, *)
+
+extension UIImage {
+    
+    /// Returns the same image with a portrait orientation.
+    func applyingPortraitOrientation() -> UIImage {
+        switch imageOrientation {
+        case .up:
+            return rotated(by: Measurement(value: Double.pi, unit: .radians), options: []) ?? self
+        case .down:
+            return rotated(by: Measurement(value: Double.pi, unit: .radians), options: [.flipOnVerticalAxis, .flipOnHorizontalAxis]) ?? self
+        case .left:
+            return self
+        case .right:
+            return rotated(by: Measurement(value: Double.pi / 2.0, unit: .radians), options: []) ?? self
+        default:
+            return self
+        }
+    }
+    
+    /// Data structure to easily express rotation options.
+    struct RotationOptions: OptionSet {
+        let rawValue: Int
+        
+        static let flipOnVerticalAxis = RotationOptions(rawValue: 1)
+        static let flipOnHorizontalAxis = RotationOptions(rawValue: 2)
+    }
+    
+    /// Rotate the image by the given angle, and perform other transformations based on the passed in options.
+    ///
+    /// - Parameters:
+    ///   - rotationAngle: The angle to rotate the image by.
+    ///   - options: Options to apply to the image.
+    /// - Returns: The new image rotated and optentially flipped (@see options).
+    func rotated(by rotationAngle: Measurement<UnitAngle>, options: RotationOptions = []) -> UIImage? {
+        guard let cgImage = self.cgImage else { return nil }
+        
+        let rotationInRadians = CGFloat(rotationAngle.converted(to: .radians).value)
+        let transform = CGAffineTransform(rotationAngle: rotationInRadians)
+        let cgImageSize = CGSize(width: cgImage.width, height: cgImage.height)
+        var rect = CGRect(origin: .zero, size: cgImageSize).applying(transform)
+        rect.origin = .zero
+        
+        let format = UIGraphicsImageRendererFormat()
+        format.scale = 1
+        
+        let renderer = UIGraphicsImageRenderer(size: rect.size, format: format)
+        
+        let image = renderer.image { renderContext in
+            renderContext.cgContext.translateBy(x: rect.midX, y: rect.midY)
+            renderContext.cgContext.rotate(by: rotationInRadians)
+            
+            let x = options.contains(.flipOnVerticalAxis) ? -1.0 : 1.0
+            let y = options.contains(.flipOnHorizontalAxis) ? 1.0 : -1.0
+            renderContext.cgContext.scaleBy(x: CGFloat(x), y: CGFloat(y))
+            
+            let drawRect = CGRect(origin: CGPoint(x: -cgImageSize.width / 2.0, y: -cgImageSize.height / 2.0), size: cgImageSize)
+            renderContext.cgContext.draw(cgImage, in: drawRect)
+        }
+        
+        return image
+    }
+    
+}

+ 34 - 0
iOSClient/Library/WeScan/Extensions/UIImage+Utils.swift

@@ -0,0 +1,34 @@
+//
+//  UIImage+Utils.swift
+//  WeScan
+//
+//  Created by Bobo on 5/25/18.
+//  Copyright © 2018 WeTransfer. All rights reserved.
+//
+
+import Foundation
+
+extension UIImage {
+    
+    /// Draws a new cropped and scaled (zoomed in) image.
+    ///
+    /// - Parameters:
+    ///   - point: The center of the new image.
+    ///   - scaleFactor: Factor by which the image should be zoomed in.
+    ///   - size: The size of the rect the image will be displayed in.
+    /// - Returns: The scaled and cropped image.
+    func scaledImage(atPoint point: CGPoint, scaleFactor: CGFloat, targetSize size: CGSize) -> UIImage? {
+        guard let cgImage = self.cgImage else {
+            return nil
+        }
+        
+        let scaledSize = CGSize(width: size.width / scaleFactor, height: size.height / scaleFactor)
+        
+        guard let croppedImage = cgImage.cropping(to: CGRect(x: point.x - scaledSize.width / 2.0, y: point.y - scaledSize.height / 2.0, width: scaledSize.width, height: scaledSize.height)) else {
+            return nil
+        }
+        
+        return UIImage(cgImage: croppedImage)
+    }
+    
+}

+ 118 - 0
iOSClient/Library/WeScan/ImageScannerController.swift

@@ -0,0 +1,118 @@
+//
+//  ImageScannerController.swift
+//  WeScan
+//
+//  Created by Boris Emorine on 2/12/18.
+//  Copyright © 2018 WeTransfer. All rights reserved.
+//
+
+import UIKit
+import AVFoundation
+
+@available(iOS 10, *)
+
+/// A set of methods that your delegate object must implement to interact with the image scanner interface.
+public protocol ImageScannerControllerDelegate: NSObjectProtocol {
+    
+    /// Tells the delegate that the user scanned a document.
+    ///
+    /// - Parameters:
+    ///   - scanner: The scanner controller object managing the scanning interface.
+    ///   - results: The results of the user scanning with the camera.
+    /// - Discussion: Your delegate's implementation of this method should dismiss the image scanner controller.
+    func imageScannerController(_ scanner: ImageScannerController, didFinishScanningWithResults results: ImageScannerResults)
+    
+    /// Tells the delegate that the user cancelled the scan operation.
+    ///
+    /// - Parameters:
+    ///   - scanner: The scanner controller object managing the scanning interface.
+    /// - Discussion: Your delegate's implementation of this method should dismiss the image scanner controller.
+    func imageScannerControllerDidCancel(_ scanner: ImageScannerController)
+    
+    /// Tells the delegate that an error occured during the user's scanning experience.
+    ///
+    /// - Parameters:
+    ///   - scanner: The scanner controller object managing the scanning interface.
+    ///   - error: The error that occured.
+    func imageScannerController(_ scanner: ImageScannerController, didFailWithError error: Error)
+}
+
+@available(iOS 10, *)
+
+/// A view controller that manages the full flow for scanning documents.
+/// The `ImageScannerController` class is meant to be presented. It consists of a series of 3 different screens which guide the user:
+/// 1. Uses the camera to capture an image with a rectangle that has been detected.
+/// 2. Edit the detected rectangle.
+/// 3. Review the cropped down version of the rectangle.
+public final class ImageScannerController: UINavigationController {
+    
+    /// The object that acts as the delegate of the `ImageScannerController`.
+    weak public var imageScannerDelegate: ImageScannerControllerDelegate?
+    
+    // MARK: - Life Cycle
+    
+    /// A black UIView, used to quickly display a black screen when the shutter button is presseed.
+    internal let blackFlashView: UIView = {
+        let view = UIView()
+        view.backgroundColor = UIColor(white: 0.0, alpha: 0.5)
+        view.isHidden = true
+        view.translatesAutoresizingMaskIntoConstraints = false
+        return view
+    }()
+    
+    public required init() {
+        let scannerViewController = ScannerViewController()
+        super.init(rootViewController: scannerViewController)
+        navigationBar.tintColor = .black
+        navigationBar.isTranslucent = false
+        self.view.addSubview(blackFlashView)
+        setupConstraints()
+    }
+    
+    public override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
+        super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
+    }
+    
+    required public init?(coder aDecoder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+    
+    private func setupConstraints() {
+        let blackFlashViewConstraints = [
+            blackFlashView.topAnchor.constraint(equalTo: view.topAnchor),
+            blackFlashView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
+            view.bottomAnchor.constraint(equalTo: blackFlashView.bottomAnchor),
+            view.trailingAnchor.constraint(equalTo: blackFlashView.trailingAnchor)
+        ]
+        
+        NSLayoutConstraint.activate(blackFlashViewConstraints)
+    }
+    
+    override public var supportedInterfaceOrientations: UIInterfaceOrientationMask {
+        return .portrait
+    }
+    
+    internal func flashToBlack() {
+        view.bringSubview(toFront: blackFlashView)
+        blackFlashView.isHidden = false
+        let flashDuration = DispatchTime.now() + 0.05
+        DispatchQueue.main.asyncAfter(deadline: flashDuration) {
+            self.blackFlashView.isHidden = true
+        }
+    }
+    
+}
+
+/// Data structure containing information about a scan.
+public struct ImageScannerResults {
+    
+    /// The original image taken by the user.
+    public var originalImage: UIImage
+    
+    /// The deskewed and cropped orignal image using the detected rectangle.
+    public var scannedImage: UIImage
+    
+    /// The detected rectangle which was used to generate the `scannedImage`.
+    public var detectedRectangle: Quadrilateral
+    
+}

+ 41 - 0
iOSClient/Library/WeScan/Protocols/Transformable.swift

@@ -0,0 +1,41 @@
+//
+//  Extendable.swift
+//  WeScan
+//
+//  Created by Boris Emorine on 2/15/18.
+//  Copyright © 2018 WeTransfer. All rights reserved.
+//
+
+import Foundation
+
+/// Objects that conform to the Transformable protocol are capable of being transformed with a `CGAffineTransform`.
+protocol Transformable {
+    
+    /// Applies the given `CGAffineTransform`.
+    ///
+    /// - Parameters:
+    ///   - t: The transform to apply
+    /// - Returns: The same object transformed by the passed in `CGAffineTransform`.
+    func applying(_ transform: CGAffineTransform) -> Self
+
+}
+
+extension Transformable {
+    
+    /// Applies multiple given transforms in the given order.
+    ///
+    /// - Parameters:
+    ///   - transforms: The transforms to apply.
+    /// - Returns: The same object transformed by the passed in `CGAffineTransform`s.
+    func applyTransforms(_ transforms: [CGAffineTransform]) -> Self {
+        
+        var transformableObject = self
+        
+        transforms.forEach { (transform) in
+            transformableObject = transformableObject.applying(transform)
+        }
+        
+        return transformableObject
+    }
+    
+}

+ 83 - 0
iOSClient/Library/WeScan/Review/ReviewViewController.swift

@@ -0,0 +1,83 @@
+//
+//  ReviewViewController.swift
+//  WeScan
+//
+//  Created by Boris Emorine on 2/25/18.
+//  Copyright © 2018 WeTransfer. All rights reserved.
+//
+
+import UIKit
+
+@available(iOS 10, *)
+
+/// The `ReviewViewController` offers an interface to review the image after it has been cropped and deskwed according to the passed in quadrilateral.
+final class ReviewViewController: UIViewController {
+    
+    lazy private var imageView: UIImageView = {
+        let imageView = UIImageView()
+        imageView.clipsToBounds = true
+        imageView.isOpaque = true
+        imageView.image = results.scannedImage
+        imageView.backgroundColor = .black
+        imageView.contentMode = .scaleAspectFit
+        imageView.translatesAutoresizingMaskIntoConstraints = false
+        return imageView
+    }()
+    
+    lazy private var doneButton: UIBarButtonItem = {
+        let title = NSLocalizedString("wescan.review.button.done", comment: "A generic done button")
+        let button = UIBarButtonItem(title: title, style: .done, target: self, action: #selector(finishScan))
+        button.tintColor = navigationController?.navigationBar.tintColor
+        return button
+    }()
+    
+    private let results: ImageScannerResults
+    
+    // MARK: - Life Cycle
+    
+    init(results: ImageScannerResults) {
+        self.results = results
+        
+        super.init(nibName: nil, bundle: nil)
+    }
+    
+    required init?(coder aDecoder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+    
+    override func viewDidLoad() {
+        super.viewDidLoad()
+
+        setupViews()
+        setupConstraints()
+        
+        title = NSLocalizedString("wescan.review.title", comment: "The review title of the ReviewController")
+        navigationItem.rightBarButtonItem = doneButton
+    }
+    
+    // MARK: Setups
+    
+    private func setupViews() {
+        view.addSubview(imageView)
+    }
+    
+    private func setupConstraints() {
+        let imageViewConstraints = [
+            imageView.topAnchor.constraint(equalTo: view.topAnchor),
+            imageView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
+            view.bottomAnchor.constraint(equalTo: imageView.bottomAnchor),
+            view.leadingAnchor.constraint(equalTo: imageView.leadingAnchor)
+        ]
+        
+        NSLayoutConstraint.activate(imageViewConstraints)
+    }
+    
+    // MARK: - Actions
+    
+    @objc private func finishScan() {
+        if let imageScannerController = navigationController as? ImageScannerController {
+            imageScannerController.imageScannerDelegate?.imageScannerController(imageScannerController, didFinishScanningWithResults: results)
+        }
+    }
+
+}

+ 289 - 0
iOSClient/Library/WeScan/Scan/CaptureSessionManager.swift

@@ -0,0 +1,289 @@
+//
+//  CaptureManager.swift
+//  WeScan
+//
+//  Created by Boris Emorine on 2/8/18.
+//  Copyright © 2018 WeTransfer. All rights reserved.
+//
+
+import Foundation
+import AVFoundation
+
+@available(iOS 10, *)
+
+/// A set of functions that inform the delegate object of the state of the detection.
+protocol RectangleDetectionDelegateProtocol: NSObjectProtocol {
+    
+    /// Called when the capture of a picture has started.
+    ///
+    /// - Parameters:
+    ///   - captureSessionManager: The `CaptureSessionManager` instance that started capturing a picture.
+    func didStartCapturingPicture(for captureSessionManager: CaptureSessionManager)
+    
+    /// Called when a quadrilateral has been detected.
+    /// - Parameters:
+    ///   - captureSessionManager: The `CaptureSessionManager` instance that has detected a quadrilateral.
+    ///   - quad: The detected quadrilateral in the coordinates of the image.
+    ///   - imageSize: The size of the image the quadrilateral has been detected on.
+    func captureSessionManager(_ captureSessionManager: CaptureSessionManager, didDetectQuad quad: Quadrilateral?, _ imageSize: CGSize)
+    
+    /// Called when a picture with or without a quadrilateral has been captured.
+    ///
+    /// - Parameters:
+    ///   - captureSessionManager: The `CaptureSessionManager` instance that has captured a picture.
+    ///   - picture: The picture that has been captured.
+    ///   - quad: The quadrilateral that was detected in the picture's coordinates if any.
+    func captureSessionManager(_ captureSessionManager: CaptureSessionManager, didCapturePicture picture: UIImage, withQuad quad: Quadrilateral?)
+    
+    /// Called when an error occured with the capture session manager.
+    /// - Parameters:
+    ///   - captureSessionManager: The `CaptureSessionManager` that encountered an error.
+    ///   - error: The encountered error.
+    func captureSessionManager(_ captureSessionManager: CaptureSessionManager, didFailWithError error: Error)
+}
+
+@available(iOS 10, *)
+
+/// The CaptureSessionManager is responsible for setting up and managing the AVCaptureSession and the functions related to capturing.
+final class CaptureSessionManager: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate {
+    
+    private let videoPreviewLayer: AVCaptureVideoPreviewLayer
+    private let captureSession = AVCaptureSession()
+    private let rectangleFunnel = RectangleFeaturesFunnel()
+    weak var delegate: RectangleDetectionDelegateProtocol?
+    private var displayedRectangleResult: RectangleDetectorResult?
+    private var photoOutput = AVCapturePhotoOutput()
+    
+    /// Whether the CaptureSessionManager should be detecting quadrilaterals.
+    private var isDetecting = true
+    
+    /// The number of times no rectangles have been found in a row.
+    private var noRectangleCount = 0
+    
+    /// The minimum number of time required by `noRectangleCount` to validate that no rectangles have been found.
+    private let noRectangleThreshold = 3
+    
+    // MARK: Life Cycle
+    
+    init?(videoPreviewLayer: AVCaptureVideoPreviewLayer) {
+        self.videoPreviewLayer = videoPreviewLayer
+        super.init()
+        
+        captureSession.beginConfiguration()
+        captureSession.sessionPreset = AVCaptureSession.Preset.photo
+        
+        photoOutput.isHighResolutionCaptureEnabled = true
+        
+        let videoOutput = AVCaptureVideoDataOutput()
+        videoOutput.alwaysDiscardsLateVideoFrames = true
+        
+        guard let inputDevice = AVCaptureDevice.default(for: AVMediaType.video),
+            let deviceInput = try? AVCaptureDeviceInput(device: inputDevice),
+            captureSession.canAddInput(deviceInput),
+            captureSession.canAddOutput(photoOutput),
+            captureSession.canAddOutput(videoOutput) else {
+                let error = ImageScannerControllerError.inputDevice
+                delegate?.captureSessionManager(self, didFailWithError: error)
+                return
+        }
+        
+        captureSession.addInput(deviceInput)
+        captureSession.addOutput(photoOutput)
+        captureSession.addOutput(videoOutput)
+        
+        videoPreviewLayer.session = captureSession
+        videoPreviewLayer.videoGravity = AVLayerVideoGravity.resizeAspectFill
+        
+        videoOutput.setSampleBufferDelegate(self, queue: DispatchQueue(label: "video_ouput_queue"))
+        
+        captureSession.commitConfiguration()
+    }
+    
+    // MARK: Capture Session Life Cycle
+    
+    /// Starts the camera and detecting quadrilaterals.
+    internal func start() {
+        let authorizationStatus = AVCaptureDevice.authorizationStatus(for: AVMediaType.video)
+        
+        switch authorizationStatus {
+        case .authorized:
+            self.captureSession.startRunning()
+            isDetecting = true
+        case .notDetermined:
+            AVCaptureDevice.requestAccess(for: AVMediaType.video, completionHandler: { (_) in
+                DispatchQueue.main.async { [weak self] in
+                    self?.start()
+                }
+            })
+        default:
+            let error = ImageScannerControllerError.authorization
+            delegate?.captureSessionManager(self, didFailWithError: error)
+        }
+    }
+    
+    internal func stop() {
+        captureSession.stopRunning()
+    }
+    
+    internal func capturePhoto() {
+        let photoSettings = AVCapturePhotoSettings()
+        photoSettings.isHighResolutionPhotoEnabled = true
+        photoSettings.isAutoStillImageStabilizationEnabled = true
+        
+        if let photoOutputConnection = self.photoOutput.connection(with: .video) {
+            photoOutputConnection.videoOrientation = AVCaptureVideoOrientation(deviceOrientation: UIDevice.current.orientation) ?? AVCaptureVideoOrientation.portrait
+        }
+        
+       photoOutput.capturePhoto(with: photoSettings, delegate: self)
+    }
+    
+    // MARK: - AVCaptureVideoDataOutputSampleBufferDelegate
+    
+    func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
+        guard isDetecting == true else {
+            return
+        }
+        
+        guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else {
+            return
+        }
+        
+        let videoOutputImage = CIImage(cvPixelBuffer: pixelBuffer)
+        let imageSize = videoOutputImage.extent.size
+        
+        guard let rectangle = RectangleDetector.rectangle(forImage: videoOutputImage) else {
+            DispatchQueue.main.async { [weak self] in
+                guard let strongSelf = self else {
+                    return
+                }
+                strongSelf.noRectangleCount += 1
+                
+                if strongSelf.noRectangleCount > strongSelf.noRectangleThreshold {
+                    strongSelf.displayedRectangleResult = nil
+                    strongSelf.delegate?.captureSessionManager(strongSelf, didDetectQuad: nil, imageSize)
+                }
+            }
+            return
+        }
+        
+        noRectangleCount = 0
+        
+        rectangleFunnel.add(rectangle, currentlyDisplayedRectangle: displayedRectangleResult?.rectangle) { (rectangle) in
+            displayRectangleResult(rectangleResult: RectangleDetectorResult(rectangle: rectangle, imageSize: imageSize))
+        }
+    }
+    
+    @discardableResult private func displayRectangleResult(rectangleResult: RectangleDetectorResult) -> Quadrilateral {
+        displayedRectangleResult = rectangleResult
+        
+        let quad = Quadrilateral(rectangleFeature: rectangleResult.rectangle).toCartesian(withHeight: rectangleResult.imageSize.height)
+        
+        DispatchQueue.main.async { [weak self] in
+            guard let strongSelf = self else {
+                return
+            }
+            
+            strongSelf.delegate?.captureSessionManager(strongSelf, didDetectQuad: quad, rectangleResult.imageSize)
+        }
+        
+        return quad
+    }
+
+}
+
+@available(iOS 10, *)
+
+extension CaptureSessionManager: AVCapturePhotoCaptureDelegate {
+
+    func photoOutput(_ captureOutput: AVCapturePhotoOutput, didFinishProcessingPhoto photoSampleBuffer: CMSampleBuffer?, previewPhoto previewPhotoSampleBuffer: CMSampleBuffer?, resolvedSettings: AVCaptureResolvedPhotoSettings, bracketSettings: AVCaptureBracketedStillImageSettings?, error: Error?) {
+        if let error = error {
+            delegate?.captureSessionManager(self, didFailWithError: error)
+            return
+        }
+        
+        isDetecting = false
+        delegate?.didStartCapturingPicture(for: self)
+        
+        if let sampleBuffer = photoSampleBuffer,
+            let imageData = AVCapturePhotoOutput.jpegPhotoDataRepresentation(forJPEGSampleBuffer: sampleBuffer, previewPhotoSampleBuffer: nil) {
+                completeImageCapture(with: imageData)
+            
+        } else {
+            let error = ImageScannerControllerError.capture
+            delegate?.captureSessionManager(self, didFailWithError: error)
+            return
+        }
+        
+    }
+    
+    @available(iOS 11.0, *)
+    func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) {
+        if let error = error {
+            delegate?.captureSessionManager(self, didFailWithError: error)
+            return
+        }
+        
+        isDetecting = false
+        delegate?.didStartCapturingPicture(for: self)
+        
+        if let imageData = photo.fileDataRepresentation() {
+            completeImageCapture(with: imageData)
+        } else {
+            let error = ImageScannerControllerError.capture
+            delegate?.captureSessionManager(self, didFailWithError: error)
+            return
+        }
+    }
+    
+    /// Completes the image capture by processing the image, and passing it to the delegate object.
+    /// This function is necessary because the capture functions for iOS 10 and 11 are decoupled.
+    private func completeImageCapture(with imageData: Data) {
+        DispatchQueue.global(qos: .background).async { [weak self] in
+            guard let image = UIImage(data: imageData) else {
+                let error = ImageScannerControllerError.capture
+                DispatchQueue.main.async {
+                    guard let strongSelf = self else {
+                        return
+                    }
+                    strongSelf.delegate?.captureSessionManager(strongSelf, didFailWithError: error)
+                }
+                return
+            }
+            
+            var angle: CGFloat = 0.0
+            
+            switch image.imageOrientation {
+            case .right:
+                angle = CGFloat.pi / 2
+            case .up:
+                angle = CGFloat.pi
+            default:
+                break
+            }
+                        
+            var quad: Quadrilateral?
+            if let displayedRectangleResult = self?.displayedRectangleResult {
+                quad = self?.displayRectangleResult(rectangleResult: displayedRectangleResult)
+                quad = quad?.scale(displayedRectangleResult.imageSize, image.size, withRotationAngle: angle)
+            }
+            
+            DispatchQueue.main.async {
+                guard let strongSelf = self else {
+                    return
+                }
+                strongSelf.delegate?.captureSessionManager(strongSelf, didCapturePicture: image, withQuad: quad)
+            }
+        }
+    }
+}
+
+/// Data structure representing the result of the detection of a quadrilateral.
+fileprivate struct RectangleDetectorResult {
+    
+    /// The detected quadrilateral.
+    let rectangle: CIRectangleFeature
+    
+    /// The size of the image the quadrilateral was detected on.
+    let imageSize: CGSize
+    
+}

+ 48 - 0
iOSClient/Library/WeScan/Scan/CloseButton.swift

@@ -0,0 +1,48 @@
+//
+//  CloseButton.swift
+//  WeScan
+//
+//  Created by Boris Emorine on 2/27/18.
+//  Copyright © 2018 WeTransfer. All rights reserved.
+//
+
+import UIKit
+
+/// A simple close button shaped like an "X".
+final class CloseButton: UIControl {
+    
+    let xLayer = CAShapeLayer()
+    
+    override init(frame: CGRect) {
+        super.init(frame: frame)
+        layer.addSublayer(xLayer)
+        backgroundColor = .clear
+        isAccessibilityElement = true
+        accessibilityTraits = UIAccessibilityTraitButton
+    }
+    
+    required init?(coder aDecoder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+
+    override func draw(_ rect: CGRect) {
+        self.clipsToBounds = false
+        xLayer.frame = rect
+        xLayer.lineWidth = 3.0
+        xLayer.path = pathForX(inRect: rect.insetBy(dx: xLayer.lineWidth / 2, dy: xLayer.lineWidth / 2)).cgPath
+        xLayer.fillColor = UIColor.clear.cgColor
+        xLayer.strokeColor = UIColor.black.cgColor
+        xLayer.lineCap = kCALineCapRound
+    }
+    
+    private func pathForX(inRect rect: CGRect) -> UIBezierPath {
+        let path = UIBezierPath()
+        path.move(to: rect.origin)
+        path.addLine(to: CGPoint(x: rect.origin.x + rect.width, y: rect.origin.y + rect.height))
+        path.move(to: CGPoint(x: rect.origin.x + rect.width, y: rect.origin.y))
+        path.addLine(to: CGPoint(x: rect.origin.x, y: rect.origin.y + rect.height))
+        
+        return path
+    }
+
+}

+ 168 - 0
iOSClient/Library/WeScan/Scan/RectangleFeaturesFunnel.swift

@@ -0,0 +1,168 @@
+//
+//  RectangleFeaturesFunnel.swift
+//  WeScan
+//
+//  Created by Boris Emorine on 2/9/18.
+//  Copyright © 2018 WeTransfer. All rights reserved.
+//
+
+import Foundation
+import AVFoundation
+
+/// `RectangleFeaturesFunnel` is used to improve the confidence of the detected rectangles.
+/// Feed rectangles to a `RectangleFeaturesFunnel` instance, and it will call the completion block with a rectangle whose confidence is high enough to be displayed.
+final class RectangleFeaturesFunnel {
+    
+    /// `RectangleMatch` is a class used to assign matching scores to rectangles.
+    private final class RectangleMatch: NSObject {
+        /// The rectangle feature object associated to this `RectangleMatch` instance.
+        let rectangleFeature: CIRectangleFeature
+        
+        /// The score to indicate how strongly the rectangle of this instance matches other recently added rectangles.
+        /// A higher score indicates that many recently added rectangles are very close to the rectangle of this instance.
+        var matchingScore = 0
+
+        init(rectangleFeature: CIRectangleFeature) {
+            self.rectangleFeature = rectangleFeature
+        }
+        
+        override var description: String {
+            return "Matching score: \(matchingScore) - Rectangle: \(rectangleFeature)"
+        }
+        
+        /// Whether the rectangle of this instance is within the distance of the given rectangle.
+        ///
+        /// - Parameters:
+        ///   - rectangle: The rectangle to compare the rectangle of this instance with.
+        ///   - threshold: The distance used to determinate if the rectangles match in pixels.
+        /// - Returns: True if both rectangles are within the given distance of each other.
+        func matches(_ rectangle: CIRectangleFeature, withThreshold threshold: CGFloat) -> Bool {
+            return rectangleFeature.isWithin(threshold, ofRectangleFeature: rectangle)
+        }
+    }
+    
+    /// The queue of last added rectangles. The first rectangle is oldest one, and the last rectangle is the most recently added one.
+    private var rectangles = [RectangleMatch]()
+    
+    /// The maximum number of rectangles to compare newly added rectangles with. Determines the maximum size of `rectangles`. Increasing this value will impact performance.
+    let maxNumberOfRectangles = 8
+    
+    /// The minimum number of rectangles needed to start making comparaisons and determining which rectangle to display. This value should always be inferior than `maxNumberOfRectangles`.
+    /// A higher value will delay the first time a rectangle is displayed.
+    let minNumberOfRectangles = 3
+    
+    /// The value in pixels used to determine if two rectangle match or not. A higher value will prevent displayed rectangles to be refreshed. On the opposite, a smaller value will make new rectangles be displayed constantly.
+    let matchingThreshold: CGFloat = 40.0
+    
+    /// The minumum number of matching rectangles (within the `rectangle` queue), to be confident enough to display a rectangle.
+    let minNumberOfMatches = 2
+
+    /// Add a rectangle to the funnel, and if a new rectangle should be displayed, the completion block will be called.
+    /// The algorithm works the following way:
+    /// 1. Makes sure that the funnel has been fed enough rectangles
+    /// 2. Removes old rectangles if needed
+    /// 3. Compares all of the recently added rectangles to find out which one match each other
+    /// 4. Within all of the recently added rectangles, finds the "best" one (@see `bestRectangle(withCurrentlyDisplayedRectangle:)`)
+    /// 5. If the best rectangle is different than the currently displayed rectangle, informs the listener that a new rectangle should be displayed
+    /// - Parameters:
+    ///   - rectangleFeature: The rectangle to feed to the funnel.
+    ///   - currentRectangle: The currently displayed rectangle. This is used to avoid displaying very close rectangles.
+    ///   - completion: The completion block called when a new rectangle should be displayed.
+    func add(_ rectangleFeature: CIRectangleFeature, currentlyDisplayedRectangle currentRectangle: CIRectangleFeature?, completion: (CIRectangleFeature) -> Void) {
+        let rectangleMatch = RectangleMatch(rectangleFeature: rectangleFeature)
+        rectangles.append(rectangleMatch)
+        
+        guard rectangles.count >= minNumberOfRectangles else {
+            return
+        }
+        
+        if rectangles.count > maxNumberOfRectangles {
+            rectangles.removeFirst()
+        }
+        
+        updateRectangleMatches()
+        
+        guard let bestRectangle = bestRectangle(withCurrentlyDisplayedRectangle: currentRectangle) else {
+            return
+        }
+        
+        if let previousRectangle = currentRectangle,
+            bestRectangle.rectangleFeature.isWithin(matchingThreshold, ofRectangleFeature: previousRectangle) {
+        } else if bestRectangle.matchingScore >= minNumberOfMatches {
+            completion(bestRectangle.rectangleFeature)
+        }
+    }
+    
+    /// Determines which rectangle is best to displayed.
+    /// The criteria used to find the best rectangle is its matching score.
+    /// If multiple rectangles have the same matching score, we use a tie breaker to find the best rectangle (@see breakTie(forRectangles:)).
+    /// Parameters:
+    ///   - currentRectangle: The currently displayed rectangle. This is used to avoid displaying very close rectangles.
+    /// Returns: The best rectangle to display given the current history.
+    private func bestRectangle(withCurrentlyDisplayedRectangle currentRectangle: CIRectangleFeature?) -> RectangleMatch? {
+        var bestMatch: RectangleMatch?
+        
+        rectangles.reversed().forEach { (rectangle) in
+            guard let best = bestMatch else {
+                bestMatch = rectangle
+                return
+            }
+            
+            if rectangle.matchingScore > best.matchingScore {
+                bestMatch = rectangle
+                return
+            } else if rectangle.matchingScore == best.matchingScore {
+                guard let currentRectangle = currentRectangle else {
+                    return
+                }
+                
+                bestMatch = breakTie(between: best, rect2: rectangle, currentRectangle: currentRectangle)
+            }
+        }
+        
+        return bestMatch
+    }
+    
+    /// Breaks a tie between two rectangles to find out which is best to display.
+    /// The first passed rectangle is returned if no other criteria could be used to break the tie.
+    /// If the first passed rectangle (rect1) is close to the currently displayed rectangle, we pick it.
+    /// Otherwise if the second passed rectangle (rect2) is close to the currently displayed rectangle, we pick this one.
+    /// Finally, if none of the passed in rectangles are close to the currently displayed rectangle, we arbitrary pick the first one.
+    /// - Parameters:
+    ///   - rect1: The first rectangle to compare.
+    ///   - rect2: The second rectangle to compare.
+    ///   - currentRectangle: The currently displayed rectangle. This is used to avoid displaying very close rectangles.
+    /// - Returns: The best rectangle to display between two rectangles with the same matching score.
+    private func breakTie(between rect1: RectangleMatch, rect2: RectangleMatch, currentRectangle: CIRectangleFeature) -> RectangleMatch {
+        if rect1.rectangleFeature.isWithin(matchingThreshold, ofRectangleFeature: currentRectangle) {
+            return rect1
+        } else if rect2.rectangleFeature.isWithin(matchingThreshold, ofRectangleFeature: currentRectangle) {
+            return rect2
+        }
+        
+        return rect1
+    }
+    
+    /// Loops through all of the rectangles of the queue, and gives them a score depending on how many they match. @see `RectangleMatch.matchingScore`
+    private func updateRectangleMatches() {
+        resetMatchingScores()
+        
+        for (i, currentRect) in rectangles.enumerated() {
+            for (j, rect) in rectangles.enumerated() {
+                if j > i && currentRect.matches(rect.rectangleFeature, withThreshold: matchingThreshold) {
+                    currentRect.matchingScore += 1
+                    rect.matchingScore += 1
+                }
+            }
+        }
+    }
+    
+    /// Resets the matching score of all of the rectangles in the queue to 0
+    private func resetMatchingScores() {
+        rectangles = rectangles.map { (rectangle) -> RectangleMatch in
+            rectangle.matchingScore = 0
+            return rectangle
+        }
+    }
+    
+}

+ 185 - 0
iOSClient/Library/WeScan/Scan/ScannerViewController.swift

@@ -0,0 +1,185 @@
+//
+//  ScannerViewController.swift
+//  WeScan
+//
+//  Created by Boris Emorine on 2/8/18.
+//  Copyright © 2018 WeTransfer. All rights reserved.
+//
+
+import UIKit
+import AVFoundation
+
+@available(iOS 10, *)
+
+/// The `ScannerViewController` offers an interface to give feedback to the user regarding quadrilaterals that are detected. It also gives the user the opportunity to capture an image with a detected rectangle.
+final class ScannerViewController: UIViewController {
+    
+    private var captureSessionManager: CaptureSessionManager?
+    private let videoPreviewlayer = AVCaptureVideoPreviewLayer()
+    
+    /// The view that draws the detected rectangles.
+    private let quadView = QuadrilateralView()
+    
+    lazy private var shutterButton: ShutterButton = {
+        let button = ShutterButton()
+        button.translatesAutoresizingMaskIntoConstraints = false
+        button.addTarget(self, action: #selector(captureImage(_:)), for: .touchUpInside)
+        return button
+    }()
+    
+    lazy private var activityIndicator: UIActivityIndicatorView = {
+        let activityIndicator = UIActivityIndicatorView(activityIndicatorStyle: .gray)
+        activityIndicator.hidesWhenStopped = true
+        activityIndicator.translatesAutoresizingMaskIntoConstraints = false
+        return activityIndicator
+    }()
+
+    lazy private var closeButton: CloseButton = {
+        let button = CloseButton(frame: CGRect(x: 0, y: 0, width: 18, height: 18))
+        button.addTarget(self, action: #selector(cancelImageScannerController(_:)), for: .touchUpInside)
+        return button
+    }()
+
+    // MARK: - Life Cycle
+
+    override func viewDidLoad() {
+        super.viewDidLoad()
+
+        title = NSLocalizedString("wescan.scanning.title", comment: "The title of the ScannerViewController")
+        navigationItem.leftBarButtonItem = UIBarButtonItem(customView: closeButton)
+
+        setupViews()
+        setupConstraints()
+        
+        captureSessionManager = CaptureSessionManager(videoPreviewLayer: videoPreviewlayer)
+        captureSessionManager?.delegate = self
+    }
+    
+    override func viewWillAppear(_ animated: Bool) {
+        super.viewWillAppear(animated)
+        quadView.removeQuadrilateral()
+        captureSessionManager?.start()
+        UIApplication.shared.isIdleTimerDisabled = true
+    }
+    
+    override func viewDidLayoutSubviews() {
+        super.viewDidLayoutSubviews()
+        
+        videoPreviewlayer.frame = view.layer.bounds
+    }
+    
+    override func viewWillDisappear(_ animated: Bool) {
+        super.viewWillDisappear(animated)
+        UIApplication.shared.isIdleTimerDisabled = false
+    }
+    
+    // MARK: - Setups
+    
+    private func setupViews() {
+        view.layer.addSublayer(videoPreviewlayer)
+        quadView.translatesAutoresizingMaskIntoConstraints = false
+        quadView.editable = false
+        view.addSubview(quadView)
+        view.addSubview(shutterButton)
+        view.addSubview(activityIndicator)
+    }
+    
+    private func setupConstraints() {
+        let quadViewConstraints = [
+            quadView.topAnchor.constraint(equalTo: view.topAnchor),
+            view.bottomAnchor.constraint(equalTo: quadView.bottomAnchor),
+            view.trailingAnchor.constraint(equalTo: quadView.trailingAnchor),
+            quadView.leadingAnchor.constraint(equalTo: view.leadingAnchor)
+        ]
+        
+        var shutterButtonBottomConstraint: NSLayoutConstraint
+
+        if #available(iOS 11.0, *) {
+            shutterButtonBottomConstraint = view.safeAreaLayoutGuide.bottomAnchor.constraint(equalTo: shutterButton.bottomAnchor, constant: 15.0)
+        } else {
+            shutterButtonBottomConstraint = view.bottomAnchor.constraint(equalTo: shutterButton.bottomAnchor, constant: 15.0)
+        }
+        
+        let shutterButtonConstraints = [
+            shutterButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
+            shutterButtonBottomConstraint,
+            shutterButton.widthAnchor.constraint(equalToConstant: 65.0),
+            shutterButton.heightAnchor.constraint(equalToConstant: 65.0)
+        ]
+        
+        let activityIndicatorConstraints = [
+            activityIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor),
+            activityIndicator.centerYAnchor.constraint(equalTo: view.centerYAnchor)
+        ]
+        
+        NSLayoutConstraint.activate(quadViewConstraints + shutterButtonConstraints + activityIndicatorConstraints)
+    }
+    
+    // MARK: - Actions
+    
+    @objc private func captureImage(_ sender: UIButton) {
+        (navigationController as? ImageScannerController)?.flashToBlack()
+        shutterButton.isUserInteractionEnabled = false
+        captureSessionManager?.capturePhoto()
+    }
+    
+    @objc private func cancelImageScannerController(_ sender: UIButton) {
+        if let imageScannerController = navigationController as? ImageScannerController {
+            imageScannerController.imageScannerDelegate?.imageScannerControllerDidCancel(imageScannerController)
+        }
+    }
+
+}
+
+@available(iOS 10, *)
+
+extension ScannerViewController: RectangleDetectionDelegateProtocol {
+    func captureSessionManager(_ captureSessionManager: CaptureSessionManager, didFailWithError error: Error) {
+        
+        activityIndicator.stopAnimating()
+        shutterButton.isUserInteractionEnabled = true
+        
+        if let imageScannerController = navigationController as? ImageScannerController {
+            imageScannerController.imageScannerDelegate?.imageScannerController(imageScannerController, didFailWithError: error)
+        }
+    }
+    
+    func didStartCapturingPicture(for captureSessionManager: CaptureSessionManager) {
+        activityIndicator.startAnimating()
+        shutterButton.isUserInteractionEnabled = false
+    }
+    
+    func captureSessionManager(_ captureSessionManager: CaptureSessionManager, didCapturePicture picture: UIImage, withQuad quad: Quadrilateral?) {
+        activityIndicator.stopAnimating()
+        
+        let editVC = EditScanViewController(image: picture, quad: quad)
+        navigationController?.pushViewController(editVC, animated: false)
+        
+        shutterButton.isUserInteractionEnabled = true
+    }
+        
+    func captureSessionManager(_ captureSessionManager: CaptureSessionManager, didDetectQuad quad: Quadrilateral?, _ imageSize: CGSize) {
+        guard let quad = quad else {
+            // If no quad has been detected, we remove the currently displayed on on the quadView.
+            quadView.removeQuadrilateral()
+            return
+        }
+        
+        let portraitImageSize = CGSize(width: imageSize.height, height: imageSize.width)
+        
+        let scaleTransform = CGAffineTransform.scaleTransform(forSize: portraitImageSize, aspectFillInSize: quadView.bounds.size)
+        let scaledImageSize = imageSize.applying(scaleTransform)
+        
+        let rotationTransform = CGAffineTransform(rotationAngle: CGFloat(Double.pi / 2.0))
+
+        let imageBounds = CGRect(x: 0.0, y: 0.0, width: scaledImageSize.width, height: scaledImageSize.height).applying(rotationTransform)
+        let translationTransform = CGAffineTransform.translateTransform(fromCenterOfRect: imageBounds, toCenterOfRect: quadView.bounds)
+
+        let transforms = [scaleTransform, rotationTransform, translationTransform]
+        
+        let transformedQuad = quad.applyTransforms(transforms)
+        
+        quadView.drawQuadrilateral(quad: transformedQuad, animated: true)
+    }
+    
+}

+ 101 - 0
iOSClient/Library/WeScan/Scan/ShutterButton.swift

@@ -0,0 +1,101 @@
+//
+//  ShutterButton.swift
+//  WeScan
+//
+//  Created by Boris Emorine on 2/26/18.
+//  Copyright © 2018 WeTransfer. All rights reserved.
+//
+
+import UIKit
+
+@available(iOS 10, *)
+
+/// A simple button used for the shutter.
+final class ShutterButton: UIControl {
+    
+    private let outterRingLayer = CAShapeLayer()
+    private let innerCircleLayer = CAShapeLayer()
+    
+    private let outterRingRatio: CGFloat = 0.80
+    private let innerRingRatio: CGFloat = 0.75
+    
+    private let impactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light)
+    
+    override var isHighlighted: Bool {
+        didSet {
+            if oldValue != isHighlighted {
+                animateInnerCircleLayer(forHighlightedState: isHighlighted)
+            }
+        }
+    }
+    
+    // MARL: Life Cycle
+    
+    override init(frame: CGRect) {
+        super.init(frame: frame)
+        layer.addSublayer(outterRingLayer)
+        layer.addSublayer(innerCircleLayer)
+        backgroundColor = .clear
+        isAccessibilityElement = true
+        accessibilityTraits = UIAccessibilityTraitButton
+        impactFeedbackGenerator.prepare()
+    }
+    
+    required init?(coder aDecoder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+    
+    // MARK: - Drawing
+    
+    override func draw(_ rect: CGRect) {
+        outterRingLayer.frame = rect
+        outterRingLayer.path = pathForOutterRing(inRect: rect).cgPath
+        outterRingLayer.fillColor = UIColor.white.cgColor
+        outterRingLayer.rasterizationScale = UIScreen.main.scale
+        outterRingLayer.shouldRasterize = true
+        
+        innerCircleLayer.frame = rect
+        innerCircleLayer.path = pathForInnerCircle(inRect: rect).cgPath
+        innerCircleLayer.fillColor = UIColor.white.cgColor
+        innerCircleLayer.rasterizationScale = UIScreen.main.scale
+        innerCircleLayer.shouldRasterize = true
+    }
+    
+    // MARK: - Animation
+    
+    private func animateInnerCircleLayer(forHighlightedState isHighlighted: Bool) {
+        let animation = CAKeyframeAnimation(keyPath: "transform")
+        var values = [CATransform3DMakeScale(1.0, 1.0, 1.0), CATransform3DMakeScale(0.9, 0.9, 0.9), CATransform3DMakeScale(0.93, 0.93, 0.93), CATransform3DMakeScale(0.9, 0.9, 0.9)]
+        if isHighlighted == false {
+            values = [CATransform3DMakeScale(0.9, 0.9, 0.9), CATransform3DMakeScale(1.0, 1.0, 1.0)]
+        }
+        animation.values = values
+        animation.isRemovedOnCompletion = false
+        animation.fillMode = kCAFillModeForwards
+        animation.duration = isHighlighted ? 0.35 : 0.10
+        
+        innerCircleLayer.add(animation, forKey: "transform")
+        impactFeedbackGenerator.impactOccurred()
+    }
+    
+    // MARK: - Paths
+    
+    private func pathForOutterRing(inRect rect: CGRect) -> UIBezierPath {
+        let path = UIBezierPath(ovalIn: rect)
+        
+        let innerRect = rect.scaleAndCenter(withRatio: outterRingRatio)
+        let innerPath = UIBezierPath(ovalIn: innerRect).reversing()
+        
+        path.append(innerPath)
+        
+        return path
+    }
+    
+    private func pathForInnerCircle(inRect rect: CGRect) -> UIBezierPath {
+        let rect = rect.scaleAndCenter(withRatio: innerRingRatio)
+        let path = UIBezierPath(ovalIn: rect)
+        
+        return path
+    }
+    
+}

+ 17 - 0
iOSClient/Library/WeScan/WeScan.h

@@ -0,0 +1,17 @@
+//
+//  WeScan.h
+//  WeScan
+//
+//  Created by Boris Emorine on 2/8/18.
+//  Copyright © 2018 WeTransfer. All rights reserved.
+//
+
+#import <UIKit/UIKit.h>
+
+//! Project version number for Scanner.
+FOUNDATION_EXPORT double ScannerVersionNumber;
+
+//! Project version string for Scanner.
+FOUNDATION_EXPORT const unsigned char ScannerVersionString[];
+
+// In this header, you should import all the public headers of your framework using statements like #import <Scanner/PublicHeader.h>