apple_kit

Apple-native services for Radroots iOS and macOS apps
git clone https://radroots.dev/git/apple_kit.git
Log | Files | Refs | README | LICENSE

RadrootsAppleDocumentScanner.swift (9877B)


      1 import Foundation
      2 
      3 #if canImport(UIKit)
      4     @preconcurrency import UIKit
      5 #endif
      6 
      7 #if canImport(VisionKit)
      8     @preconcurrency import VisionKit
      9 #endif
     10 
     11 public final class RadrootsAppleDocumentScanner: RadrootsDocumentScanner, @unchecked Sendable {
     12     private let fileAccess: RadrootsAppleFileAccess
     13     private let callbackTimeout: TimeInterval
     14 
     15     #if canImport(UIKit)
     16         private let viewControllerProvider: RadrootsAppleViewControllerProvider
     17     #endif
     18 
     19     #if canImport(UIKit)
     20         public init(
     21             fileAccess: RadrootsAppleFileAccess,
     22             callbackTimeout: TimeInterval = 120
     23         ) {
     24             self.fileAccess = fileAccess
     25             self.callbackTimeout = callbackTimeout
     26             viewControllerProvider = {
     27                 try RadrootsAppleUIKitPresentation.activeViewController(service: "document scanner")
     28             }
     29         }
     30 
     31         public init(
     32             fileAccess: RadrootsAppleFileAccess,
     33             callbackTimeout: TimeInterval = 120,
     34             viewControllerProvider: @escaping RadrootsAppleViewControllerProvider
     35         ) {
     36             self.fileAccess = fileAccess
     37             self.callbackTimeout = callbackTimeout
     38             self.viewControllerProvider = viewControllerProvider
     39         }
     40     #else
     41         public init(
     42             fileAccess: RadrootsAppleFileAccess,
     43             callbackTimeout: TimeInterval = 120
     44         ) {
     45             self.fileAccess = fileAccess
     46             self.callbackTimeout = callbackTimeout
     47         }
     48     #endif
     49 
     50     public func currentSupport() async throws -> RadrootsDocumentScannerSupport {
     51         #if canImport(UIKit) && canImport(VisionKit)
     52             let available = await MainActor.run {
     53                 VNDocumentCameraViewController.isSupported
     54             }
     55             return try RadrootsDocumentScannerSupport(
     56                 interactiveScanAvailable: available,
     57                 multiPageSupported: available,
     58                 supportedOutputKinds: available ? [.pdf] : []
     59             )
     60         #else
     61             return try Self.unavailableSupport()
     62         #endif
     63     }
     64 
     65     public func scanDocument(_ request: RadrootsDocumentScanRequest) async throws
     66         -> RadrootsScannedDocument
     67     {
     68         #if canImport(UIKit) && canImport(VisionKit)
     69             let support = try await currentSupport()
     70             guard support.interactiveScanAvailable else {
     71                 throw RadrootsCaptureIntakeError.unavailable
     72             }
     73             let presenter: UIViewController
     74             do {
     75                 presenter = try await MainActor.run {
     76                 try viewControllerProvider()
     77             }
     78             } catch {
     79                 throw RadrootsAppleMediaPicker.adapt(error: error)
     80             }
     81             let writer = RadrootsAppleDocumentScanWriter(fileAccess: fileAccess)
     82             let coordinatorID = UUID()
     83             return try await RadrootsAppleCaptureAsyncSupport.awaitMainActorCallback(
     84                 timeout: callbackTimeout,
     85                 timeoutMessage: "timed out while presenting document scanner"
     86             ) { completion, setCleanup in
     87                 let controller = VNDocumentCameraViewController()
     88                 let coordinator = RadrootsAppleDocumentScannerCoordinator(
     89                     writer: writer,
     90                     request: request,
     91                     coordinatorID: coordinatorID
     92                 )
     93                 coordinator.completion = completion
     94                 controller.delegate = coordinator
     95                 setCleanup {
     96                     coordinator.cancelPresentation(controller)
     97                 }
     98                 RadrootsApplePresentationRetainer.shared.store(coordinator, id: coordinatorID)
     99                 presenter.present(controller, animated: true)
    100             }
    101         #else
    102             throw RadrootsCaptureIntakeError.unavailable
    103         #endif
    104     }
    105 
    106     static func unavailableSupport() throws -> RadrootsDocumentScannerSupport {
    107         try RadrootsDocumentScannerSupport(
    108             interactiveScanAvailable: false,
    109             multiPageSupported: false,
    110             supportedOutputKinds: []
    111         )
    112     }
    113 }
    114 
    115 #if canImport(UIKit) && canImport(VisionKit)
    116     @MainActor
    117     private final class RadrootsAppleDocumentScannerCoordinator: NSObject,
    118         @preconcurrency VNDocumentCameraViewControllerDelegate
    119     {
    120         var completion: (@Sendable (Result<RadrootsScannedDocument, RadrootsCaptureIntakeError>) -> Void)?
    121 
    122         private let writer: RadrootsAppleDocumentScanWriter
    123         private let request: RadrootsDocumentScanRequest
    124         private let coordinatorID: UUID
    125         private var didResolve: Bool
    126 
    127         init(
    128             writer: RadrootsAppleDocumentScanWriter,
    129             request: RadrootsDocumentScanRequest,
    130             coordinatorID: UUID
    131         ) {
    132             self.writer = writer
    133             self.request = request
    134             self.coordinatorID = coordinatorID
    135             didResolve = false
    136         }
    137 
    138         func documentCameraViewControllerDidCancel(_ controller: VNDocumentCameraViewController) {
    139             controller.dismiss(animated: true)
    140             finish(.failure(.userCancelled))
    141         }
    142 
    143         func documentCameraViewController(
    144             _ controller: VNDocumentCameraViewController,
    145             didFailWithError error: Error
    146         ) {
    147             controller.dismiss(animated: true)
    148             finish(.failure(RadrootsAppleMediaPicker.adapt(error: error)))
    149         }
    150 
    151         func documentCameraViewController(
    152             _ controller: VNDocumentCameraViewController,
    153             didFinishWith scan: VNDocumentCameraScan
    154         ) {
    155             controller.dismiss(animated: true)
    156             do {
    157                 let rendered = try Self.renderPDF(scan)
    158                 try finish(
    159                     .success(
    160                         writer.persistPDF(
    161                     data: rendered.data,
    162                     pageCount: rendered.pageCount,
    163                     destinationScope: request.destinationScope
    164                 )))
    165             } catch {
    166                 finish(.failure(RadrootsAppleMediaPicker.adapt(error: error)))
    167             }
    168         }
    169 
    170         private static func renderPDF(_ scan: VNDocumentCameraScan) throws -> (data: Data, pageCount: UInt16) {
    171             let pageCount = scan.pageCount
    172             guard pageCount > 0 else {
    173                 throw RadrootsCaptureIntakeError.invalidRequest
    174             }
    175             guard pageCount <= Int(UInt16.max) else {
    176                 throw RadrootsCaptureIntakeError.invalidRequest
    177             }
    178             let images = (0 ..< pageCount).map { scan.imageOfPage(at: $0) }
    179             let bounds = pageBounds(images: images)
    180             let renderer = UIGraphicsPDFRenderer(bounds: bounds)
    181             let data = renderer.pdfData { context in
    182                 for image in images {
    183                     context.beginPage()
    184                     image.draw(in: aspectFitRect(imageSize: image.size, bounds: bounds))
    185                 }
    186             }
    187             guard !data.isEmpty else {
    188                 throw RadrootsCaptureIntakeError.transientFailure
    189             }
    190             return (data, UInt16(pageCount))
    191         }
    192 
    193         private static func pageBounds(images: [UIImage]) -> CGRect {
    194             let fallback = CGSize(width: 612, height: 792)
    195             let width = images.map(\.size.width).filter { $0 > 0 }.max() ?? fallback.width
    196             let height = images.map(\.size.height).filter { $0 > 0 }.max() ?? fallback.height
    197             return CGRect(origin: .zero, size: CGSize(width: width, height: height))
    198         }
    199 
    200         private static func aspectFitRect(imageSize: CGSize, bounds: CGRect) -> CGRect {
    201             guard imageSize.width > 0, imageSize.height > 0 else {
    202                 return bounds
    203             }
    204             let widthScale = bounds.width / imageSize.width
    205             let heightScale = bounds.height / imageSize.height
    206             let scale = min(widthScale, heightScale)
    207             let scaledSize = CGSize(width: imageSize.width * scale, height: imageSize.height * scale)
    208             return CGRect(
    209                 origin: CGPoint(
    210                     x: bounds.origin.x + ((bounds.width - scaledSize.width) / 2),
    211                     y: bounds.origin.y + ((bounds.height - scaledSize.height) / 2)
    212                 ),
    213                 size: scaledSize
    214             )
    215         }
    216 
    217         func cancelPresentation(_ controller: VNDocumentCameraViewController) {
    218             guard !didResolve else { return }
    219             controller.dismiss(animated: true)
    220             finish(.failure(.transientFailure))
    221         }
    222 
    223         private func finish(_ result: Result<RadrootsScannedDocument, RadrootsCaptureIntakeError>) {
    224             guard !didResolve else { return }
    225             didResolve = true
    226             let completion = completion
    227             self.completion = nil
    228             RadrootsApplePresentationRetainer.shared.release(id: coordinatorID)
    229             completion?(result)
    230         }
    231     }
    232 #endif
    233 
    234 private final class RadrootsAppleDocumentScanWriter: @unchecked Sendable {
    235     private let fileAccess: RadrootsAppleFileAccess
    236 
    237     init(fileAccess: RadrootsAppleFileAccess) {
    238         self.fileAccess = fileAccess
    239     }
    240 
    241     func persistPDF(
    242         data: Data,
    243         pageCount: UInt16,
    244         destinationScope: RadrootsFileScope
    245     ) throws -> RadrootsScannedDocument {
    246         let file = RadrootsFileReference(
    247             scope: destinationScope,
    248             relativePath: "capture_intake/document_scanner/\(UUID().uuidString.lowercased())/scan.pdf"
    249         )
    250         try fileAccess.write(.inline(data), to: file)
    251         return try RadrootsScannedDocument(
    252             file: file,
    253             outputKind: .pdf,
    254             suggestedFilename: "scan.pdf",
    255             mediaType: "application/pdf",
    256             pageCount: pageCount,
    257             sizeBytes: UInt64(data.count),
    258             capturedAt: Date()
    259         )
    260     }
    261 }