apple_kit

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

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 }