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 }