RadrootsAppleDocumentScanner.swift (9272B)
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 self.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 -> RadrootsScannedDocument { 66 #if canImport(UIKit) && canImport(VisionKit) 67 let support = try await currentSupport() 68 guard support.interactiveScanAvailable else { 69 throw RadrootsCaptureIntakeError.unavailable("document scanner is unavailable") 70 } 71 let presenter = try await MainActor.run { 72 try viewControllerProvider() 73 } 74 let writer = RadrootsAppleDocumentScanWriter(fileAccess: fileAccess) 75 let coordinatorID = UUID() 76 return try await RadrootsAppleCaptureAsyncSupport.awaitMainActorCallback( 77 timeout: callbackTimeout, 78 timeoutMessage: "timed out while presenting document scanner" 79 ) { completion, setCleanup in 80 let controller = VNDocumentCameraViewController() 81 let coordinator = RadrootsAppleDocumentScannerCoordinator( 82 writer: writer, 83 request: request, 84 coordinatorID: coordinatorID 85 ) 86 coordinator.completion = completion 87 controller.delegate = coordinator 88 setCleanup { 89 coordinator.cancelPresentation(controller) 90 } 91 RadrootsApplePresentationRetainer.shared.store(coordinator, id: coordinatorID) 92 presenter.present(controller, animated: true) 93 } 94 #else 95 throw RadrootsCaptureIntakeError.unavailable("document scanner is unavailable") 96 #endif 97 } 98 99 static func unavailableSupport() throws -> RadrootsDocumentScannerSupport { 100 try RadrootsDocumentScannerSupport( 101 interactiveScanAvailable: false, 102 multiPageSupported: false, 103 supportedOutputKinds: [] 104 ) 105 } 106 } 107 108 #if canImport(UIKit) && canImport(VisionKit) 109 @MainActor 110 private final class RadrootsAppleDocumentScannerCoordinator: NSObject, @preconcurrency VNDocumentCameraViewControllerDelegate { 111 var completion: (@Sendable (Result<RadrootsScannedDocument, RadrootsCaptureIntakeError>) -> Void)? 112 113 private let writer: RadrootsAppleDocumentScanWriter 114 private let request: RadrootsDocumentScanRequest 115 private let coordinatorID: UUID 116 private var didResolve: Bool 117 118 init( 119 writer: RadrootsAppleDocumentScanWriter, 120 request: RadrootsDocumentScanRequest, 121 coordinatorID: UUID 122 ) { 123 self.writer = writer 124 self.request = request 125 self.coordinatorID = coordinatorID 126 self.didResolve = false 127 } 128 129 func documentCameraViewControllerDidCancel(_ controller: VNDocumentCameraViewController) { 130 controller.dismiss(animated: true) 131 finish(.failure(.userCancelled("document scan was cancelled"))) 132 } 133 134 func documentCameraViewController( 135 _ controller: VNDocumentCameraViewController, 136 didFailWithError error: Error 137 ) { 138 controller.dismiss(animated: true) 139 finish(.failure(RadrootsAppleMediaPicker.adapt(error: error))) 140 } 141 142 func documentCameraViewController( 143 _ controller: VNDocumentCameraViewController, 144 didFinishWith scan: VNDocumentCameraScan 145 ) { 146 controller.dismiss(animated: true) 147 do { 148 let rendered = try Self.renderPDF(scan) 149 finish(.success(try writer.persistPDF( 150 data: rendered.data, 151 pageCount: rendered.pageCount, 152 destinationScope: request.destinationScope 153 ))) 154 } catch { 155 finish(.failure(RadrootsAppleMediaPicker.adapt(error: error))) 156 } 157 } 158 159 private static func renderPDF(_ scan: VNDocumentCameraScan) throws -> (data: Data, pageCount: UInt16) { 160 let pageCount = scan.pageCount 161 guard pageCount > 0 else { 162 throw RadrootsCaptureIntakeError.invalidRequest("document scanner requires at least one page") 163 } 164 guard pageCount <= Int(UInt16.max) else { 165 throw RadrootsCaptureIntakeError.invalidRequest("document scanner page count exceeds supported range") 166 } 167 let images = (0..<pageCount).map { scan.imageOfPage(at: $0) } 168 let bounds = pageBounds(images: images) 169 let renderer = UIGraphicsPDFRenderer(bounds: bounds) 170 let data = renderer.pdfData { context in 171 for image in images { 172 context.beginPage() 173 image.draw(in: aspectFitRect(imageSize: image.size, bounds: bounds)) 174 } 175 } 176 guard !data.isEmpty else { 177 throw RadrootsCaptureIntakeError.transientFailure("document scanner failed to render a pdf") 178 } 179 return (data, UInt16(pageCount)) 180 } 181 182 private static func pageBounds(images: [UIImage]) -> CGRect { 183 let fallback = CGSize(width: 612, height: 792) 184 let width = images.map(\.size.width).filter { $0 > 0 }.max() ?? fallback.width 185 let height = images.map(\.size.height).filter { $0 > 0 }.max() ?? fallback.height 186 return CGRect(origin: .zero, size: CGSize(width: width, height: height)) 187 } 188 189 private static func aspectFitRect(imageSize: CGSize, bounds: CGRect) -> CGRect { 190 guard imageSize.width > 0, imageSize.height > 0 else { 191 return bounds 192 } 193 let widthScale = bounds.width / imageSize.width 194 let heightScale = bounds.height / imageSize.height 195 let scale = min(widthScale, heightScale) 196 let scaledSize = CGSize(width: imageSize.width * scale, height: imageSize.height * scale) 197 return CGRect( 198 origin: CGPoint( 199 x: bounds.origin.x + ((bounds.width - scaledSize.width) / 2), 200 y: bounds.origin.y + ((bounds.height - scaledSize.height) / 2) 201 ), 202 size: scaledSize 203 ) 204 } 205 206 func cancelPresentation(_ controller: VNDocumentCameraViewController) { 207 guard !didResolve else { return } 208 controller.dismiss(animated: true) 209 finish(.failure(.transientFailure("document scanner presentation was cancelled"))) 210 } 211 212 private func finish(_ result: Result<RadrootsScannedDocument, RadrootsCaptureIntakeError>) { 213 guard !didResolve else { return } 214 didResolve = true 215 let completion = completion 216 self.completion = nil 217 RadrootsApplePresentationRetainer.shared.release(id: coordinatorID) 218 completion?(result) 219 } 220 } 221 #endif 222 223 private final class RadrootsAppleDocumentScanWriter: @unchecked Sendable { 224 private let fileAccess: RadrootsAppleFileAccess 225 226 init(fileAccess: RadrootsAppleFileAccess) { 227 self.fileAccess = fileAccess 228 } 229 230 func persistPDF( 231 data: Data, 232 pageCount: UInt16, 233 destinationScope: RadrootsFileScope 234 ) throws -> RadrootsScannedDocument { 235 let file = RadrootsFileReference( 236 scope: destinationScope, 237 relativePath: "capture_intake/document_scanner/\(UUID().uuidString.lowercased())/scan.pdf" 238 ) 239 try fileAccess.write(.inline(data), to: file) 240 return try RadrootsScannedDocument( 241 file: file, 242 outputKind: .pdf, 243 suggestedFilename: "scan.pdf", 244 mediaType: "application/pdf", 245 pageCount: pageCount, 246 sizeBytes: UInt64(data.count), 247 capturedAt: Date() 248 ) 249 } 250 }