apple_kit

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

commit 872f5554a694fe7e4da00c1d7841785bf1ab4a4d
parent d4a4fbb498a44885c6dea5f8f58a173d87674a6a
Author: triesap <tyson@radroots.org>
Date:   Sun, 14 Jun 2026 13:05:43 -0700

capture: add Apple document scanner

- add AppleKit-owned PDF document scanner service
- render scanned pages into local PDF documents
- reuse capture presentation and async retention helpers
- cover unavailable platform behavior with Swift tests

Diffstat:
ASources/RadrootsKit/RadrootsAppleDocumentScanner.swift | 232+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MSources/RadrootsKit/RadrootsAppleMediaPicker.swift | 6+++---
ATests/RadrootsKitTests/RadrootsAppleDocumentScannerTests.swift | 30++++++++++++++++++++++++++++++
3 files changed, 265 insertions(+), 3 deletions(-)

diff --git a/Sources/RadrootsKit/RadrootsAppleDocumentScanner.swift b/Sources/RadrootsKit/RadrootsAppleDocumentScanner.swift @@ -0,0 +1,232 @@ +import Foundation + +#if canImport(UIKit) +@preconcurrency import UIKit +#endif + +#if canImport(VisionKit) +@preconcurrency import VisionKit +#endif + +public final class RadrootsAppleDocumentScanner: RadrootsDocumentScanner, @unchecked Sendable { + private let fileAccess: RadrootsAppleFileAccess + private let callbackTimeout: TimeInterval + + #if canImport(UIKit) + private let viewControllerProvider: RadrootsAppleViewControllerProvider + #endif + + #if canImport(UIKit) + public init( + fileAccess: RadrootsAppleFileAccess, + callbackTimeout: TimeInterval = 120, + viewControllerProvider: @escaping RadrootsAppleViewControllerProvider = { + try RadrootsAppleUIKitPresentation.activeViewController(service: "document scanner") + } + ) { + self.fileAccess = fileAccess + self.callbackTimeout = callbackTimeout + self.viewControllerProvider = viewControllerProvider + } + #else + public init( + fileAccess: RadrootsAppleFileAccess, + callbackTimeout: TimeInterval = 120 + ) { + self.fileAccess = fileAccess + self.callbackTimeout = callbackTimeout + } + #endif + + public func currentSupport() async throws -> RadrootsDocumentScannerSupport { + #if canImport(UIKit) && canImport(VisionKit) + let available = await MainActor.run { + VNDocumentCameraViewController.isSupported + } + return try RadrootsDocumentScannerSupport( + interactiveScanAvailable: available, + multiPageSupported: available, + supportedOutputKinds: available ? [.pdf] : [] + ) + #else + return try Self.unavailableSupport() + #endif + } + + public func scanDocument(_ request: RadrootsDocumentScanRequest) async throws -> RadrootsScannedDocument { + #if canImport(UIKit) && canImport(VisionKit) + let support = try await currentSupport() + guard support.interactiveScanAvailable else { + throw RadrootsCaptureIntakeError.unavailable("document scanner is unavailable") + } + let presenter = try await MainActor.run { + try viewControllerProvider() + } + let writer = RadrootsAppleDocumentScanWriter(fileAccess: fileAccess) + let coordinatorID = UUID() + return try await RadrootsAppleCaptureAsyncSupport.awaitMainActorCallback( + timeout: callbackTimeout, + timeoutMessage: "timed out while presenting document scanner" + ) { completion in + let controller = VNDocumentCameraViewController() + let coordinator = RadrootsAppleDocumentScannerCoordinator( + writer: writer, + request: request, + coordinatorID: coordinatorID + ) + coordinator.completion = completion + controller.delegate = coordinator + RadrootsApplePresentationRetainer.shared.store(coordinator, id: coordinatorID) + presenter.present(controller, animated: true) + } + #else + throw RadrootsCaptureIntakeError.unavailable("document scanner is unavailable") + #endif + } + + static func unavailableSupport() throws -> RadrootsDocumentScannerSupport { + try RadrootsDocumentScannerSupport( + interactiveScanAvailable: false, + multiPageSupported: false, + supportedOutputKinds: [] + ) + } +} + +#if canImport(UIKit) && canImport(VisionKit) +@MainActor +private final class RadrootsAppleDocumentScannerCoordinator: NSObject, @preconcurrency VNDocumentCameraViewControllerDelegate { + var completion: (@Sendable (Result<RadrootsScannedDocument, RadrootsCaptureIntakeError>) -> Void)? + + private let writer: RadrootsAppleDocumentScanWriter + private let request: RadrootsDocumentScanRequest + private let coordinatorID: UUID + private var didResolve: Bool + + init( + writer: RadrootsAppleDocumentScanWriter, + request: RadrootsDocumentScanRequest, + coordinatorID: UUID + ) { + self.writer = writer + self.request = request + self.coordinatorID = coordinatorID + self.didResolve = false + } + + func documentCameraViewControllerDidCancel(_ controller: VNDocumentCameraViewController) { + controller.dismiss(animated: true) + finish(.failure(.userCancelled("document scan was cancelled"))) + } + + func documentCameraViewController( + _ controller: VNDocumentCameraViewController, + didFailWithError error: Error + ) { + controller.dismiss(animated: true) + finish(.failure(RadrootsAppleMediaPicker.adapt(error: error))) + } + + func documentCameraViewController( + _ controller: VNDocumentCameraViewController, + didFinishWith scan: VNDocumentCameraScan + ) { + controller.dismiss(animated: true) + do { + let rendered = try Self.renderPDF(scan) + finish(.success(try writer.persistPDF( + data: rendered.data, + pageCount: rendered.pageCount, + destinationScope: request.destinationScope + ))) + } catch { + finish(.failure(RadrootsAppleMediaPicker.adapt(error: error))) + } + } + + private static func renderPDF(_ scan: VNDocumentCameraScan) throws -> (data: Data, pageCount: UInt16) { + let pageCount = scan.pageCount + guard pageCount > 0 else { + throw RadrootsCaptureIntakeError.invalidRequest("document scanner requires at least one page") + } + guard pageCount <= Int(UInt16.max) else { + throw RadrootsCaptureIntakeError.invalidRequest("document scanner page count exceeds supported range") + } + let images = (0..<pageCount).map { scan.imageOfPage(at: $0) } + let bounds = pageBounds(images: images) + let renderer = UIGraphicsPDFRenderer(bounds: bounds) + let data = renderer.pdfData { context in + for image in images { + context.beginPage() + image.draw(in: aspectFitRect(imageSize: image.size, bounds: bounds)) + } + } + guard !data.isEmpty else { + throw RadrootsCaptureIntakeError.transientFailure("document scanner failed to render a pdf") + } + return (data, UInt16(pageCount)) + } + + private static func pageBounds(images: [UIImage]) -> CGRect { + let fallback = CGSize(width: 612, height: 792) + let width = images.map(\.size.width).filter { $0 > 0 }.max() ?? fallback.width + let height = images.map(\.size.height).filter { $0 > 0 }.max() ?? fallback.height + return CGRect(origin: .zero, size: CGSize(width: width, height: height)) + } + + private static func aspectFitRect(imageSize: CGSize, bounds: CGRect) -> CGRect { + guard imageSize.width > 0, imageSize.height > 0 else { + return bounds + } + let widthScale = bounds.width / imageSize.width + let heightScale = bounds.height / imageSize.height + let scale = min(widthScale, heightScale) + let scaledSize = CGSize(width: imageSize.width * scale, height: imageSize.height * scale) + return CGRect( + origin: CGPoint( + x: bounds.origin.x + ((bounds.width - scaledSize.width) / 2), + y: bounds.origin.y + ((bounds.height - scaledSize.height) / 2) + ), + size: scaledSize + ) + } + + private func finish(_ result: Result<RadrootsScannedDocument, RadrootsCaptureIntakeError>) { + guard !didResolve else { return } + didResolve = true + let completion = completion + self.completion = nil + RadrootsApplePresentationRetainer.shared.release(id: coordinatorID) + completion?(result) + } +} +#endif + +private final class RadrootsAppleDocumentScanWriter: @unchecked Sendable { + private let fileAccess: RadrootsAppleFileAccess + + init(fileAccess: RadrootsAppleFileAccess) { + self.fileAccess = fileAccess + } + + func persistPDF( + data: Data, + pageCount: UInt16, + destinationScope: RadrootsFileScope + ) throws -> RadrootsScannedDocument { + let file = RadrootsFileReference( + scope: destinationScope, + relativePath: "capture_intake/document_scanner/\(UUID().uuidString.lowercased())/scan.pdf" + ) + try fileAccess.write(.inline(data), to: file) + return try RadrootsScannedDocument( + file: file, + outputKind: .pdf, + suggestedFilename: "scan.pdf", + mediaType: "application/pdf", + pageCount: pageCount, + sizeBytes: UInt64(data.count), + capturedAt: Date() + ) + } +} diff --git a/Sources/RadrootsKit/RadrootsAppleMediaPicker.swift b/Sources/RadrootsKit/RadrootsAppleMediaPicker.swift @@ -221,7 +221,7 @@ private extension RadrootsAppleMediaPicker { } @MainActor -private enum RadrootsAppleUIKitPresentation { +enum RadrootsAppleUIKitPresentation { static func activeViewController(service: String) throws -> UIViewController { let scenes = UIApplication.shared.connectedScenes .compactMap { $0 as? UIWindowScene } @@ -432,7 +432,7 @@ private final class RadrootsAppleCameraCaptureCoordinator: NSObject, UIImagePick #endif @MainActor -private final class RadrootsApplePresentationRetainer { +final class RadrootsApplePresentationRetainer { static let shared = RadrootsApplePresentationRetainer() private var retainers: [UUID: AnyObject] @@ -618,7 +618,7 @@ private final class RadrootsAppleMediaAssetWriter: @unchecked Sendable { } } -private enum RadrootsAppleCaptureAsyncSupport { +enum RadrootsAppleCaptureAsyncSupport { static func awaitMainActorCallback<Value: Sendable>( timeout: TimeInterval, timeoutMessage: String, diff --git a/Tests/RadrootsKitTests/RadrootsAppleDocumentScannerTests.swift b/Tests/RadrootsKitTests/RadrootsAppleDocumentScannerTests.swift @@ -0,0 +1,30 @@ +import Foundation +import Testing +@testable import RadrootsKit + +#if !(canImport(UIKit) && canImport(VisionKit)) +@Test func appleDocumentScannerReportsUnavailableWithoutVisionKitScanner() async throws { + let scanner = try RadrootsAppleDocumentScanner(fileAccess: documentScannerTestFileAccess()) + let support = try await scanner.currentSupport() + + #expect(!support.interactiveScanAvailable) + #expect(!support.multiPageSupported) + #expect(support.supportedOutputKinds.isEmpty) + + await #expect(throws: RadrootsCaptureIntakeError.unavailable("document scanner is unavailable")) { + _ = try await scanner.scanDocument(RadrootsDocumentScanRequest()) + } +} +#endif + +private func documentScannerTestFileAccess() throws -> RadrootsAppleFileAccess { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("radroots-document-scanner-\(UUID().uuidString)", isDirectory: true) + let roots = try RadrootsAppleFileRoots( + appIdentifier: "org.radroots.document-scanner-test", + dataRoot: root.appendingPathComponent("data", isDirectory: true), + cacheRoot: root.appendingPathComponent("cache", isDirectory: true), + temporaryRoot: root.appendingPathComponent("temporary", isDirectory: true) + ) + return RadrootsAppleFileAccess(roots: roots) +}