|
|
@@ -0,0 +1,959 @@
|
|
|
+import Cocoa
|
|
|
+import ImageCaptureCore
|
|
|
+import PDFKit
|
|
|
+import UniformTypeIdentifiers
|
|
|
+import Vision
|
|
|
+
|
|
|
+// MARK: - Errors
|
|
|
+
|
|
|
+enum ScanError: LocalizedError {
|
|
|
+ case noScannerFound
|
|
|
+ case scanFailed
|
|
|
+ case invalidImage
|
|
|
+ case exportFailed
|
|
|
+ case cancelled
|
|
|
+
|
|
|
+ var errorDescription: String? {
|
|
|
+ switch self {
|
|
|
+ case .noScannerFound:
|
|
|
+ return "No scanner was found. Connect a scanner or import an image instead."
|
|
|
+ case .scanFailed:
|
|
|
+ return "The scan could not be completed. Please try again."
|
|
|
+ case .invalidImage:
|
|
|
+ return "The selected file could not be opened as an image."
|
|
|
+ case .exportFailed:
|
|
|
+ return "The scan could not be saved."
|
|
|
+ case .cancelled:
|
|
|
+ return "Scan cancelled."
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// MARK: - Scan File Service
|
|
|
+
|
|
|
+enum ScanFileService {
|
|
|
+ private static var activeScannerSession: ScanDeviceSession?
|
|
|
+
|
|
|
+ static func present(from window: NSWindow?) {
|
|
|
+ let hostWindow = window ?? NSApp.keyWindow
|
|
|
+ showSourcePicker(from: hostWindow) { source in
|
|
|
+ guard let source else { return }
|
|
|
+ switch source {
|
|
|
+ case .scanner:
|
|
|
+ startScannerScan(from: hostWindow)
|
|
|
+ case .importImage:
|
|
|
+ importImage(from: hostWindow)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ static func addPage(from window: NSWindow?, completion: @escaping (NSImage?) -> Void) {
|
|
|
+ let hostWindow = window ?? NSApp.keyWindow
|
|
|
+ showSourcePicker(from: hostWindow) { source in
|
|
|
+ guard let source else {
|
|
|
+ completion(nil)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ switch source {
|
|
|
+ case .scanner:
|
|
|
+ startScannerScan(from: hostWindow) { result in
|
|
|
+ switch result {
|
|
|
+ case .success(let image):
|
|
|
+ completion(image)
|
|
|
+ case .failure:
|
|
|
+ completion(nil)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ case .importImage:
|
|
|
+ importImage(from: hostWindow) { result in
|
|
|
+ switch result {
|
|
|
+ case .success(let image):
|
|
|
+ completion(image)
|
|
|
+ case .failure:
|
|
|
+ completion(nil)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private enum ScanSource {
|
|
|
+ case scanner
|
|
|
+ case importImage
|
|
|
+ }
|
|
|
+
|
|
|
+ private static func showSourcePicker(from window: NSWindow?, completion: @escaping (ScanSource?) -> Void) {
|
|
|
+ let alert = NSAlert()
|
|
|
+ alert.messageText = "Scan Document"
|
|
|
+ alert.informativeText = "Choose how you want to capture your document."
|
|
|
+ alert.alertStyle = .informational
|
|
|
+ alert.addButton(withTitle: "Use Scanner")
|
|
|
+ alert.addButton(withTitle: "Import Image")
|
|
|
+ alert.addButton(withTitle: "Cancel")
|
|
|
+
|
|
|
+ let handler: (NSApplication.ModalResponse) -> Void = { response in
|
|
|
+ switch response {
|
|
|
+ case .alertFirstButtonReturn:
|
|
|
+ completion(.scanner)
|
|
|
+ case .alertSecondButtonReturn:
|
|
|
+ completion(.importImage)
|
|
|
+ default:
|
|
|
+ completion(nil)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if let window {
|
|
|
+ alert.beginSheetModal(for: window, completionHandler: handler)
|
|
|
+ } else {
|
|
|
+ handler(alert.runModal())
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private static func startScannerScan(from window: NSWindow?, completion: ((Result<NSImage, Error>) -> Void)? = nil) {
|
|
|
+ let session = ScanDeviceSession()
|
|
|
+ activeScannerSession = session
|
|
|
+ session.scan { result in
|
|
|
+ activeScannerSession = nil
|
|
|
+ switch result {
|
|
|
+ case .success(let url):
|
|
|
+ Task { @MainActor in
|
|
|
+ do {
|
|
|
+ let image = try await processImage(at: url)
|
|
|
+ if let completion {
|
|
|
+ completion(.success(image))
|
|
|
+ } else {
|
|
|
+ presentScanPreview(pages: [image], from: window)
|
|
|
+ }
|
|
|
+ } catch {
|
|
|
+ if let completion {
|
|
|
+ completion(.failure(error))
|
|
|
+ } else {
|
|
|
+ showScanError(error, from: window)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ case .failure(let error):
|
|
|
+ if let completion {
|
|
|
+ completion(.failure(error))
|
|
|
+ } else if case ScanError.cancelled = error {
|
|
|
+ return
|
|
|
+ } else {
|
|
|
+ showScannerFallback(error: error, from: window)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private static func showScannerFallback(error: Error, from window: NSWindow?) {
|
|
|
+ let alert = NSAlert()
|
|
|
+ alert.messageText = "Scanner Unavailable"
|
|
|
+ alert.informativeText = "\(error.localizedDescription)\n\nYou can import a photo of your document instead."
|
|
|
+ alert.alertStyle = .warning
|
|
|
+ alert.addButton(withTitle: "Import Image")
|
|
|
+ alert.addButton(withTitle: "Cancel")
|
|
|
+
|
|
|
+ let handler: (NSApplication.ModalResponse) -> Void = { response in
|
|
|
+ guard response == .alertFirstButtonReturn else { return }
|
|
|
+ importImage(from: window)
|
|
|
+ }
|
|
|
+
|
|
|
+ if let window {
|
|
|
+ alert.beginSheetModal(for: window, completionHandler: handler)
|
|
|
+ } else {
|
|
|
+ if alert.runModal() == .alertFirstButtonReturn {
|
|
|
+ handler(.alertFirstButtonReturn)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private static func importImage(from window: NSWindow?, completion: ((Result<NSImage, Error>) -> Void)? = nil) {
|
|
|
+ let panel = NSOpenPanel()
|
|
|
+ panel.title = "Import Document Image"
|
|
|
+ panel.message = "Select a photo or scan of your document."
|
|
|
+ panel.prompt = "Import"
|
|
|
+ panel.canChooseDirectories = false
|
|
|
+ panel.canChooseFiles = true
|
|
|
+ panel.allowsMultipleSelection = false
|
|
|
+ panel.allowsOtherFileTypes = false
|
|
|
+ panel.allowedContentTypes = [
|
|
|
+ .jpeg, .png, .heic, .heif, .tiff, .bmp, .gif, .webP,
|
|
|
+ ].compactMap { $0 }
|
|
|
+ panel.directoryURL = FileManager.default.urls(for: .picturesDirectory, in: .userDomainMask).first
|
|
|
+
|
|
|
+ let finish: (NSApplication.ModalResponse) -> Void = { response in
|
|
|
+ guard response == .OK, let url = panel.url else {
|
|
|
+ if completion == nil { return }
|
|
|
+ completion?(.failure(ScanError.cancelled))
|
|
|
+ return
|
|
|
+ }
|
|
|
+ Task { @MainActor in
|
|
|
+ do {
|
|
|
+ let image = try await processImage(at: url)
|
|
|
+ if let completion {
|
|
|
+ completion(.success(image))
|
|
|
+ } else {
|
|
|
+ presentScanPreview(pages: [image], from: window)
|
|
|
+ }
|
|
|
+ } catch {
|
|
|
+ if let completion {
|
|
|
+ completion(.failure(error))
|
|
|
+ } else {
|
|
|
+ showScanError(error, from: window)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if let window {
|
|
|
+ panel.beginSheetModal(for: window, completionHandler: finish)
|
|
|
+ } else if panel.runModal() == .OK {
|
|
|
+ finish(.OK)
|
|
|
+ } else if let completion {
|
|
|
+ completion(.failure(ScanError.cancelled))
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private static func processImage(at url: URL) async throws -> NSImage {
|
|
|
+ let accessed = url.startAccessingSecurityScopedResource()
|
|
|
+ defer {
|
|
|
+ if accessed { url.stopAccessingSecurityScopedResource() }
|
|
|
+ }
|
|
|
+
|
|
|
+ guard let image = NSImage(contentsOf: url),
|
|
|
+ let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
|
|
+ throw ScanError.invalidImage
|
|
|
+ }
|
|
|
+
|
|
|
+ return await ScanDocumentProcessor.process(cgImage) ?? image
|
|
|
+ }
|
|
|
+
|
|
|
+ private static func presentScanPreview(pages: [NSImage], from window: NSWindow?) {
|
|
|
+ guard !pages.isEmpty else { return }
|
|
|
+ let hostWindow = window ?? NSApp.keyWindow
|
|
|
+ guard let viewController = hostWindow?.contentViewController as? ViewController else { return }
|
|
|
+ viewController.presentScanFile(pages: pages)
|
|
|
+ }
|
|
|
+
|
|
|
+ private static func showScanError(_ error: Error, from window: NSWindow?) {
|
|
|
+ let alert = NSAlert()
|
|
|
+ alert.messageText = "Scan Failed"
|
|
|
+ alert.informativeText = error.localizedDescription
|
|
|
+ alert.alertStyle = .warning
|
|
|
+ alert.addButton(withTitle: "OK")
|
|
|
+ if let window {
|
|
|
+ alert.beginSheetModal(for: window)
|
|
|
+ } else {
|
|
|
+ alert.runModal()
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// MARK: - Scanner Session
|
|
|
+
|
|
|
+private final class ScanDeviceSession: NSObject, ICDeviceBrowserDelegate, ICDeviceDelegate, ICScannerDeviceDelegate {
|
|
|
+ private var browser: ICDeviceBrowser?
|
|
|
+ private var scanners: [ICScannerDevice] = []
|
|
|
+ private var activeScanner: ICScannerDevice?
|
|
|
+ private var completion: ((Result<URL, Error>) -> Void)?
|
|
|
+ private var discoveryTimer: Timer?
|
|
|
+ private var didComplete = false
|
|
|
+
|
|
|
+ func scan(completion: @escaping (Result<URL, Error>) -> Void) {
|
|
|
+ self.completion = completion
|
|
|
+ let browser = ICDeviceBrowser()
|
|
|
+ browser.delegate = self
|
|
|
+ browser.browsedDeviceTypeMask = .scanner
|
|
|
+ self.browser = browser
|
|
|
+ browser.start()
|
|
|
+
|
|
|
+ discoveryTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: false) { [weak self] _ in
|
|
|
+ self?.finishDiscovery()
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private func finishDiscovery() {
|
|
|
+ guard !didComplete else { return }
|
|
|
+ discoveryTimer?.invalidate()
|
|
|
+ discoveryTimer = nil
|
|
|
+
|
|
|
+ guard let scanner = scanners.first else {
|
|
|
+ browser?.stop()
|
|
|
+ browser = nil
|
|
|
+ finish(.failure(ScanError.noScannerFound))
|
|
|
+ return
|
|
|
+ }
|
|
|
+ beginScan(with: scanner)
|
|
|
+ }
|
|
|
+
|
|
|
+ private func beginScan(with scanner: ICScannerDevice) {
|
|
|
+ activeScanner = scanner
|
|
|
+ scanner.delegate = self
|
|
|
+ scanner.requestOpenSession()
|
|
|
+ }
|
|
|
+
|
|
|
+ private func finish(_ result: Result<URL, Error>) {
|
|
|
+ guard !didComplete else { return }
|
|
|
+ didComplete = true
|
|
|
+ discoveryTimer?.invalidate()
|
|
|
+ discoveryTimer = nil
|
|
|
+ browser?.stop()
|
|
|
+ browser = nil
|
|
|
+ activeScanner?.requestCloseSession()
|
|
|
+ activeScanner = nil
|
|
|
+ completion?(result)
|
|
|
+ completion = nil
|
|
|
+ }
|
|
|
+
|
|
|
+ func deviceBrowser(_ browser: ICDeviceBrowser, didAdd device: ICDevice, moreComing: Bool) {
|
|
|
+ guard let scanner = device as? ICScannerDevice else { return }
|
|
|
+ if !scanners.contains(where: { $0 === scanner }) {
|
|
|
+ scanners.append(scanner)
|
|
|
+ }
|
|
|
+ if !moreComing, discoveryTimer != nil {
|
|
|
+ finishDiscovery()
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ func deviceBrowser(_ browser: ICDeviceBrowser, didRemove device: ICDevice, moreGoing: Bool) {
|
|
|
+ guard let scanner = device as? ICScannerDevice else { return }
|
|
|
+ scanners.removeAll { $0 === scanner }
|
|
|
+ }
|
|
|
+
|
|
|
+ func device(_ device: ICDevice, didOpenSessionWithError error: Error?) {
|
|
|
+ guard device === activeScanner, let scanner = device as? ICScannerDevice else { return }
|
|
|
+ if let error {
|
|
|
+ finish(.failure(error))
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ if scanner.availableFunctionalUnitTypes.contains(where: {
|
|
|
+ ($0 as? NSNumber)?.uintValue == ICScannerFunctionalUnitType.flatbed.rawValue
|
|
|
+ }) {
|
|
|
+ scanner.requestSelect(.flatbed)
|
|
|
+ } else if let first = scanner.availableFunctionalUnitTypes.first as? NSNumber {
|
|
|
+ scanner.requestSelect(ICScannerFunctionalUnitType(rawValue: first.uintValue) ?? .flatbed)
|
|
|
+ } else {
|
|
|
+ scanner.requestScan()
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ func device(_ device: ICDevice, didCloseSessionWithError error: Error?) {}
|
|
|
+
|
|
|
+ func didRemove(_ device: ICDevice) {
|
|
|
+ guard let scanner = device as? ICScannerDevice else { return }
|
|
|
+ scanners.removeAll { $0 === scanner }
|
|
|
+ if activeScanner === scanner {
|
|
|
+ activeScanner = nil
|
|
|
+ finish(.failure(ScanError.scanFailed))
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ func scannerDevice(_ scanner: ICScannerDevice, didSelect functionalUnit: ICScannerFunctionalUnit, error: Error?) {
|
|
|
+ guard scanner === activeScanner else { return }
|
|
|
+ if let error {
|
|
|
+ finish(.failure(error))
|
|
|
+ return
|
|
|
+ }
|
|
|
+ scanner.requestScan()
|
|
|
+ }
|
|
|
+
|
|
|
+ func scannerDevice(_ scanner: ICScannerDevice, didScanTo url: URL) {
|
|
|
+ finish(.success(url))
|
|
|
+ }
|
|
|
+
|
|
|
+ func scannerDevice(_ scanner: ICScannerDevice, didCompleteScanWithError error: Error?) {
|
|
|
+ if let error {
|
|
|
+ finish(.failure(error))
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// MARK: - Document Processing
|
|
|
+
|
|
|
+enum ScanDocumentProcessor {
|
|
|
+ static func process(_ cgImage: CGImage) async -> NSImage? {
|
|
|
+ await withCheckedContinuation { continuation in
|
|
|
+ let request = VNDetectRectanglesRequest { request, _ in
|
|
|
+ guard let observation = (request.results as? [VNRectangleObservation])?.first else {
|
|
|
+ continuation.resume(returning: imageFromCGImage(cgImage))
|
|
|
+ return
|
|
|
+ }
|
|
|
+ let corrected = perspectiveCorrectedImage(from: cgImage, observation: observation)
|
|
|
+ continuation.resume(returning: corrected)
|
|
|
+ }
|
|
|
+ request.minimumConfidence = 0.55
|
|
|
+ request.maximumObservations = 1
|
|
|
+ request.minimumAspectRatio = 0.25
|
|
|
+ request.maximumAspectRatio = 1.0
|
|
|
+ request.minimumSize = 0.15
|
|
|
+
|
|
|
+ let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
|
|
|
+ do {
|
|
|
+ try handler.perform([request])
|
|
|
+ } catch {
|
|
|
+ continuation.resume(returning: imageFromCGImage(cgImage))
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private static func imageFromCGImage(_ cgImage: CGImage) -> NSImage {
|
|
|
+ let rep = NSBitmapImageRep(cgImage: cgImage)
|
|
|
+ let image = NSImage()
|
|
|
+ image.addRepresentation(rep)
|
|
|
+ return image
|
|
|
+ }
|
|
|
+
|
|
|
+ private static func perspectiveCorrectedImage(from cgImage: CGImage, observation: VNRectangleObservation) -> NSImage? {
|
|
|
+ let ciImage = CIImage(cgImage: cgImage)
|
|
|
+ let size = ciImage.extent.size
|
|
|
+
|
|
|
+ func convert(_ point: CGPoint) -> CGPoint {
|
|
|
+ CGPoint(x: point.x * size.width, y: (1 - point.y) * size.height)
|
|
|
+ }
|
|
|
+
|
|
|
+ guard let filter = CIFilter(name: "CIPerspectiveCorrection") else { return nil }
|
|
|
+ filter.setValue(ciImage, forKey: kCIInputImageKey)
|
|
|
+ filter.setValue(CIVector(cgPoint: convert(observation.topLeft)), forKey: "inputTopLeft")
|
|
|
+ filter.setValue(CIVector(cgPoint: convert(observation.topRight)), forKey: "inputTopRight")
|
|
|
+ filter.setValue(CIVector(cgPoint: convert(observation.bottomLeft)), forKey: "inputBottomLeft")
|
|
|
+ filter.setValue(CIVector(cgPoint: convert(observation.bottomRight)), forKey: "inputBottomRight")
|
|
|
+
|
|
|
+ guard let output = filter.outputImage else { return nil }
|
|
|
+ let context = CIContext(options: nil)
|
|
|
+ guard let rendered = context.createCGImage(output, from: output.extent) else { return nil }
|
|
|
+ return imageFromCGImage(rendered)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// MARK: - Scan Export
|
|
|
+
|
|
|
+enum ScanExporter {
|
|
|
+ static func save(pages: [NSImage], from window: NSWindow?) {
|
|
|
+ guard !pages.isEmpty else { return }
|
|
|
+
|
|
|
+ let format = AppSettings.scanFormat
|
|
|
+ let panel = NSSavePanel()
|
|
|
+ panel.title = "Save Scan"
|
|
|
+ panel.message = "Choose where to save your scanned document."
|
|
|
+ panel.prompt = "Save"
|
|
|
+ panel.canCreateDirectories = true
|
|
|
+
|
|
|
+ switch format {
|
|
|
+ case .pdf:
|
|
|
+ panel.allowedContentTypes = [.pdf]
|
|
|
+ panel.nameFieldStringValue = "Scan.pdf"
|
|
|
+ case .jpeg:
|
|
|
+ panel.allowedContentTypes = [.jpeg]
|
|
|
+ panel.nameFieldStringValue = pages.count > 1 ? "Scan Page 1.jpg" : "Scan.jpg"
|
|
|
+ case .png:
|
|
|
+ panel.allowedContentTypes = [.png]
|
|
|
+ panel.nameFieldStringValue = pages.count > 1 ? "Scan Page 1.png" : "Scan.png"
|
|
|
+ }
|
|
|
+
|
|
|
+ let hostWindow = window ?? NSApp.keyWindow
|
|
|
+ let response: NSApplication.ModalResponse
|
|
|
+ if let hostWindow {
|
|
|
+ response = panel.runModal()
|
|
|
+ } else {
|
|
|
+ response = panel.runModal()
|
|
|
+ }
|
|
|
+ guard response == .OK, let url = panel.url else { return }
|
|
|
+
|
|
|
+ let scaledPages = pages.map { scaleForResolution($0) }
|
|
|
+ let saved: Bool
|
|
|
+ switch format {
|
|
|
+ case .pdf:
|
|
|
+ saved = writePDF(pages: scaledPages, to: url)
|
|
|
+ case .jpeg:
|
|
|
+ saved = pages.count == 1
|
|
|
+ ? writeJPEG(scaledPages[0], to: url)
|
|
|
+ : writeMultipleImages(scaledPages, baseURL: url, format: .jpeg)
|
|
|
+ case .png:
|
|
|
+ saved = pages.count == 1
|
|
|
+ ? writePNG(scaledPages[0], to: url)
|
|
|
+ : writeMultipleImages(scaledPages, baseURL: url, format: .png)
|
|
|
+ }
|
|
|
+
|
|
|
+ if !saved {
|
|
|
+ showSaveFailed(from: hostWindow)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private static func scaleForResolution(_ image: NSImage) -> NSImage {
|
|
|
+ let targetDPI = AppSettings.scanResolution.dpi
|
|
|
+ let scale = targetDPI / 300.0
|
|
|
+ guard scale != 1 else { return image }
|
|
|
+
|
|
|
+ let newSize = NSSize(width: image.size.width * scale, height: image.size.height * scale)
|
|
|
+ let scaled = NSImage(size: newSize)
|
|
|
+ scaled.lockFocus()
|
|
|
+ image.draw(in: NSRect(origin: .zero, size: newSize), from: .zero, operation: .copy, fraction: 1)
|
|
|
+ scaled.unlockFocus()
|
|
|
+ return scaled
|
|
|
+ }
|
|
|
+
|
|
|
+ private static func writePDF(pages: [NSImage], to url: URL) -> Bool {
|
|
|
+ let document = PDFDocument()
|
|
|
+ for (index, image) in pages.enumerated() {
|
|
|
+ guard let page = PDFPage(image: image) else { return false }
|
|
|
+ document.insert(page, at: index)
|
|
|
+ }
|
|
|
+ return document.write(to: url)
|
|
|
+ }
|
|
|
+
|
|
|
+ private static func writeJPEG(_ image: NSImage, to url: URL) -> Bool {
|
|
|
+ guard let tiff = image.tiffRepresentation,
|
|
|
+ let bitmap = NSBitmapImageRep(data: tiff),
|
|
|
+ let data = bitmap.representation(using: .jpeg, properties: [.compressionFactor: 0.9]) else {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ return (try? data.write(to: url)) != nil
|
|
|
+ }
|
|
|
+
|
|
|
+ private static func writePNG(_ image: NSImage, to url: URL) -> Bool {
|
|
|
+ guard let tiff = image.tiffRepresentation,
|
|
|
+ let bitmap = NSBitmapImageRep(data: tiff),
|
|
|
+ let data = bitmap.representation(using: .png, properties: [:]) else {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ return (try? data.write(to: url)) != nil
|
|
|
+ }
|
|
|
+
|
|
|
+ private static func writeMultipleImages(_ pages: [NSImage], baseURL: URL, format: ScanFormat) -> Bool {
|
|
|
+ let directory = baseURL.deletingLastPathComponent()
|
|
|
+ let baseName = baseURL.deletingPathExtension().lastPathComponent
|
|
|
+ let ext = format == .jpeg ? "jpg" : "png"
|
|
|
+
|
|
|
+ for (index, page) in pages.enumerated() {
|
|
|
+ let pageURL = directory.appendingPathComponent("\(baseName) \(index + 1).\(ext)")
|
|
|
+ let ok = format == .jpeg ? writeJPEG(page, to: pageURL) : writePNG(page, to: pageURL)
|
|
|
+ if !ok { return false }
|
|
|
+ }
|
|
|
+ return true
|
|
|
+ }
|
|
|
+
|
|
|
+ private static func showSaveFailed(from window: NSWindow?) {
|
|
|
+ let alert = NSAlert()
|
|
|
+ alert.messageText = "Save Failed"
|
|
|
+ alert.informativeText = "The scan could not be saved to the selected location."
|
|
|
+ alert.alertStyle = .warning
|
|
|
+ alert.addButton(withTitle: "OK")
|
|
|
+ if let window {
|
|
|
+ alert.beginSheetModal(for: window)
|
|
|
+ } else {
|
|
|
+ alert.runModal()
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+private extension ScanResolution {
|
|
|
+ var dpi: CGFloat {
|
|
|
+ switch self {
|
|
|
+ case .dpi150: return 150
|
|
|
+ case .dpi300: return 300
|
|
|
+ case .dpi600: return 600
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// MARK: - Overlay
|
|
|
+
|
|
|
+final class ScanFileOverlayView: NSView, AppearanceRefreshable {
|
|
|
+ var onDismiss: (() -> Void)?
|
|
|
+
|
|
|
+ private var pages: [NSImage]
|
|
|
+ private var currentIndex = 0
|
|
|
+
|
|
|
+ private let backButton = ScanFileToolbarButton(symbolName: "chevron.left", accessibilityLabel: "Back")
|
|
|
+ private let titleLabel = NSTextField(labelWithString: "Scan File")
|
|
|
+ private let counterLabel = NSTextField(labelWithString: "")
|
|
|
+ private let previewContainer = NSView()
|
|
|
+ private let imageView = NSImageView()
|
|
|
+ private let addPageButton = ScanFileSecondaryButton(title: "Add Page", symbolName: "plus")
|
|
|
+ private let saveButton = ScanFileSecondaryButton(title: "Save", symbolName: "square.and.arrow.down")
|
|
|
+ private let printButton = ScanFilePrintButton()
|
|
|
+
|
|
|
+ init(pages: [NSImage]) {
|
|
|
+ self.pages = pages
|
|
|
+ super.init(frame: .zero)
|
|
|
+ translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ setup()
|
|
|
+ refreshAppearance()
|
|
|
+ showPage(at: 0)
|
|
|
+ NotificationCenter.default.addObserver(
|
|
|
+ self,
|
|
|
+ selector: #selector(appearanceDidChange),
|
|
|
+ name: .appearanceDidChange,
|
|
|
+ object: nil
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ deinit {
|
|
|
+ NotificationCenter.default.removeObserver(self)
|
|
|
+ }
|
|
|
+
|
|
|
+ @objc private func appearanceDidChange() {
|
|
|
+ refreshAppearance()
|
|
|
+ }
|
|
|
+
|
|
|
+ @available(*, unavailable)
|
|
|
+ required init?(coder: NSCoder) { nil }
|
|
|
+
|
|
|
+ func refreshAppearance() {
|
|
|
+ layer?.backgroundColor = AppTheme.background.cgColor
|
|
|
+ titleLabel.textColor = AppTheme.textPrimary
|
|
|
+ counterLabel.textColor = AppTheme.textSecondary
|
|
|
+ previewContainer.layer?.backgroundColor = AppTheme.cardBackground.cgColor
|
|
|
+ previewContainer.layer?.borderColor = AppTheme.paywallBorder.cgColor
|
|
|
+ backButton.refreshAppearance()
|
|
|
+ addPageButton.refreshAppearance()
|
|
|
+ saveButton.refreshAppearance()
|
|
|
+ printButton.refreshAppearance()
|
|
|
+ }
|
|
|
+
|
|
|
+ func present(in parent: NSView) {
|
|
|
+ guard superview == nil else { return }
|
|
|
+ parent.addSubview(self)
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
+ leadingAnchor.constraint(equalTo: parent.leadingAnchor),
|
|
|
+ trailingAnchor.constraint(equalTo: parent.trailingAnchor),
|
|
|
+ topAnchor.constraint(equalTo: parent.topAnchor),
|
|
|
+ bottomAnchor.constraint(equalTo: parent.bottomAnchor),
|
|
|
+ ])
|
|
|
+ alphaValue = 0
|
|
|
+ NSAnimationContext.runAnimationGroup { context in
|
|
|
+ context.duration = 0.2
|
|
|
+ animator().alphaValue = 1
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ func dismiss(animated: Bool = true) {
|
|
|
+ let remove = { [weak self] in
|
|
|
+ self?.removeFromSuperview()
|
|
|
+ self?.onDismiss?()
|
|
|
+ }
|
|
|
+ guard animated else {
|
|
|
+ remove()
|
|
|
+ return
|
|
|
+ }
|
|
|
+ NSAnimationContext.runAnimationGroup({ context in
|
|
|
+ context.duration = 0.15
|
|
|
+ animator().alphaValue = 0
|
|
|
+ }, completionHandler: remove)
|
|
|
+ }
|
|
|
+
|
|
|
+ private func setup() {
|
|
|
+ wantsLayer = true
|
|
|
+
|
|
|
+ titleLabel.font = AppTheme.semiboldFont(size: 18)
|
|
|
+ titleLabel.alignment = .center
|
|
|
+ titleLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+
|
|
|
+ counterLabel.font = AppTheme.regularFont(size: 13)
|
|
|
+ counterLabel.alignment = .center
|
|
|
+ counterLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+
|
|
|
+ previewContainer.wantsLayer = true
|
|
|
+ previewContainer.layer?.cornerRadius = AppTheme.contentPanelCornerRadius
|
|
|
+ previewContainer.layer?.borderWidth = 1.5
|
|
|
+ previewContainer.applyCardShadow()
|
|
|
+ previewContainer.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ previewContainer.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
|
|
+ previewContainer.setContentHuggingPriority(.defaultLow, for: .vertical)
|
|
|
+ previewContainer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
|
|
+ previewContainer.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
|
|
|
+
|
|
|
+ imageView.imageScaling = .scaleProportionallyUpOrDown
|
|
|
+ imageView.imageAlignment = .alignCenter
|
|
|
+ imageView.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ imageView.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
|
|
+ imageView.setContentHuggingPriority(.defaultLow, for: .vertical)
|
|
|
+ imageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
|
|
+ imageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
|
|
|
+
|
|
|
+ backButton.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ addPageButton.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ saveButton.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ printButton.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+
|
|
|
+ backButton.onClick = { [weak self] in self?.dismiss() }
|
|
|
+ addPageButton.onClick = { [weak self] in self?.addPage() }
|
|
|
+ saveButton.onClick = { [weak self] in self?.saveScan() }
|
|
|
+ printButton.onClick = { [weak self] in self?.printScan() }
|
|
|
+
|
|
|
+ previewContainer.addSubview(imageView)
|
|
|
+ addSubview(backButton)
|
|
|
+ addSubview(titleLabel)
|
|
|
+ addSubview(counterLabel)
|
|
|
+ addSubview(previewContainer)
|
|
|
+
|
|
|
+ let actionRow = NSStackView(views: [addPageButton, saveButton])
|
|
|
+ actionRow.orientation = .horizontal
|
|
|
+ actionRow.spacing = 12
|
|
|
+ actionRow.distribution = .fillEqually
|
|
|
+ actionRow.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ actionRow.setContentHuggingPriority(.required, for: .vertical)
|
|
|
+ actionRow.setContentCompressionResistancePriority(.required, for: .vertical)
|
|
|
+ addSubview(actionRow)
|
|
|
+ addSubview(printButton)
|
|
|
+
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
+ backButton.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 24),
|
|
|
+ backButton.topAnchor.constraint(equalTo: topAnchor, constant: 20),
|
|
|
+ backButton.widthAnchor.constraint(equalToConstant: 40),
|
|
|
+ backButton.heightAnchor.constraint(equalToConstant: 40),
|
|
|
+
|
|
|
+ titleLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
|
|
|
+ titleLabel.centerYAnchor.constraint(equalTo: backButton.centerYAnchor),
|
|
|
+
|
|
|
+ counterLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
|
|
|
+ counterLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 4),
|
|
|
+
|
|
|
+ previewContainer.leadingAnchor.constraint(equalTo: leadingAnchor, constant: AppTheme.contentPanelInset),
|
|
|
+ previewContainer.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -AppTheme.contentPanelInset),
|
|
|
+ previewContainer.topAnchor.constraint(equalTo: backButton.bottomAnchor, constant: 28),
|
|
|
+ previewContainer.bottomAnchor.constraint(equalTo: actionRow.topAnchor, constant: -20),
|
|
|
+
|
|
|
+ imageView.leadingAnchor.constraint(equalTo: previewContainer.leadingAnchor, constant: 12),
|
|
|
+ imageView.trailingAnchor.constraint(equalTo: previewContainer.trailingAnchor, constant: -12),
|
|
|
+ imageView.topAnchor.constraint(equalTo: previewContainer.topAnchor, constant: 12),
|
|
|
+ imageView.bottomAnchor.constraint(equalTo: previewContainer.bottomAnchor, constant: -12),
|
|
|
+
|
|
|
+ actionRow.leadingAnchor.constraint(equalTo: leadingAnchor, constant: AppTheme.contentPanelInset),
|
|
|
+ actionRow.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -AppTheme.contentPanelInset),
|
|
|
+ actionRow.bottomAnchor.constraint(equalTo: printButton.topAnchor, constant: -16),
|
|
|
+ addPageButton.heightAnchor.constraint(equalToConstant: 44),
|
|
|
+ saveButton.heightAnchor.constraint(equalToConstant: 44),
|
|
|
+
|
|
|
+ printButton.centerXAnchor.constraint(equalTo: centerXAnchor),
|
|
|
+ printButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -28),
|
|
|
+ printButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 220),
|
|
|
+ ])
|
|
|
+ }
|
|
|
+
|
|
|
+ private func showPage(at index: Int) {
|
|
|
+ guard pages.indices.contains(index) else { return }
|
|
|
+ currentIndex = index
|
|
|
+ imageView.image = pages[index]
|
|
|
+ if pages.count > 1 {
|
|
|
+ counterLabel.stringValue = "Page \(index + 1) of \(pages.count)"
|
|
|
+ } else {
|
|
|
+ counterLabel.stringValue = "1 page"
|
|
|
+ }
|
|
|
+ counterLabel.isHidden = false
|
|
|
+ }
|
|
|
+
|
|
|
+ private func addPage() {
|
|
|
+ ScanFileService.addPage(from: window) { [weak self] image in
|
|
|
+ guard let self, let image else { return }
|
|
|
+ pages.append(image)
|
|
|
+ showPage(at: pages.count - 1)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private func saveScan() {
|
|
|
+ ScanExporter.save(pages: pages, from: window)
|
|
|
+ }
|
|
|
+
|
|
|
+ private func printScan() {
|
|
|
+ guard pages.indices.contains(currentIndex) else { return }
|
|
|
+ PrintService.printPhoto(pages[currentIndex], title: "Scan", from: window)
|
|
|
+
|
|
|
+ if currentIndex < pages.count - 1 {
|
|
|
+ showPage(at: currentIndex + 1)
|
|
|
+ } else if pages.count == 1 {
|
|
|
+ dismiss()
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// MARK: - Controls
|
|
|
+
|
|
|
+private final class ScanFileToolbarButton: NSControl, AppearanceRefreshable {
|
|
|
+ var onClick: (() -> Void)?
|
|
|
+
|
|
|
+ private let iconView = NSImageView()
|
|
|
+
|
|
|
+ init(symbolName: String, accessibilityLabel: String) {
|
|
|
+ super.init(frame: .zero)
|
|
|
+ toolTip = accessibilityLabel
|
|
|
+ wantsLayer = true
|
|
|
+ layer?.cornerRadius = 12
|
|
|
+
|
|
|
+ if let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: accessibilityLabel) {
|
|
|
+ let config = NSImage.SymbolConfiguration(pointSize: 15, weight: .semibold)
|
|
|
+ iconView.image = image.withSymbolConfiguration(config)
|
|
|
+ }
|
|
|
+ iconView.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+
|
|
|
+ addSubview(iconView)
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
+ iconView.centerXAnchor.constraint(equalTo: centerXAnchor),
|
|
|
+ iconView.centerYAnchor.constraint(equalTo: centerYAnchor),
|
|
|
+ ])
|
|
|
+ refreshAppearance()
|
|
|
+ }
|
|
|
+
|
|
|
+ @available(*, unavailable)
|
|
|
+ required init?(coder: NSCoder) { nil }
|
|
|
+
|
|
|
+ func refreshAppearance() {
|
|
|
+ layer?.backgroundColor = AppTheme.elevatedBackground.cgColor
|
|
|
+ layer?.borderColor = AppTheme.paywallBorder.cgColor
|
|
|
+ layer?.borderWidth = 1.5
|
|
|
+ iconView.contentTintColor = AppTheme.textPrimary
|
|
|
+ }
|
|
|
+
|
|
|
+ override func mouseUp(with event: NSEvent) {
|
|
|
+ guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
|
|
|
+ onClick?()
|
|
|
+ }
|
|
|
+
|
|
|
+ override func resetCursorRects() {
|
|
|
+ addCursorRect(bounds, cursor: .pointingHand)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+private final class ScanFileSecondaryButton: NSControl, AppearanceRefreshable {
|
|
|
+ var onClick: (() -> Void)?
|
|
|
+
|
|
|
+ private let titleLabel = NSTextField(labelWithString: "")
|
|
|
+ private let iconView = NSImageView()
|
|
|
+ private var hoverTracker: HoverTracker?
|
|
|
+ private var isHovered = false
|
|
|
+
|
|
|
+ init(title: String, symbolName: String) {
|
|
|
+ super.init(frame: .zero)
|
|
|
+ wantsLayer = true
|
|
|
+ layer?.cornerRadius = 14
|
|
|
+
|
|
|
+ titleLabel.stringValue = title
|
|
|
+ titleLabel.font = AppTheme.semiboldFont(size: 14)
|
|
|
+ titleLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+
|
|
|
+ if let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: title) {
|
|
|
+ let config = NSImage.SymbolConfiguration(pointSize: 13, weight: .semibold)
|
|
|
+ iconView.image = image.withSymbolConfiguration(config)
|
|
|
+ }
|
|
|
+ iconView.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+
|
|
|
+ addSubview(titleLabel)
|
|
|
+ addSubview(iconView)
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
+ heightAnchor.constraint(equalToConstant: 44),
|
|
|
+ iconView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16),
|
|
|
+ iconView.centerYAnchor.constraint(equalTo: centerYAnchor),
|
|
|
+ titleLabel.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 8),
|
|
|
+ titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
|
|
|
+ titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -16),
|
|
|
+ ])
|
|
|
+
|
|
|
+ hoverTracker = HoverTracker(view: self) { [weak self] hovering in
|
|
|
+ self?.setHovered(hovering)
|
|
|
+ }
|
|
|
+ refreshAppearance()
|
|
|
+ }
|
|
|
+
|
|
|
+ @available(*, unavailable)
|
|
|
+ required init?(coder: NSCoder) { nil }
|
|
|
+
|
|
|
+ func refreshAppearance() {
|
|
|
+ layer?.backgroundColor = (isHovered ? AppTheme.elevatedBackground.highlight(withLevel: 0.05) ?? AppTheme.elevatedBackground : AppTheme.elevatedBackground).cgColor
|
|
|
+ layer?.borderColor = AppTheme.paywallBorder.cgColor
|
|
|
+ layer?.borderWidth = 1.5
|
|
|
+ titleLabel.textColor = AppTheme.textPrimary
|
|
|
+ iconView.contentTintColor = AppTheme.textPrimary
|
|
|
+ }
|
|
|
+
|
|
|
+ private func setHovered(_ hovering: Bool) {
|
|
|
+ isHovered = hovering
|
|
|
+ refreshAppearance()
|
|
|
+ }
|
|
|
+
|
|
|
+ override func mouseUp(with event: NSEvent) {
|
|
|
+ guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
|
|
|
+ onClick?()
|
|
|
+ }
|
|
|
+
|
|
|
+ override func resetCursorRects() {
|
|
|
+ addCursorRect(bounds, cursor: .pointingHand)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+private final class ScanFilePrintButton: NSControl, AppearanceRefreshable {
|
|
|
+ var onClick: (() -> Void)?
|
|
|
+
|
|
|
+ private let titleLabel = NSTextField(labelWithString: "Print Scan")
|
|
|
+ private let iconView = NSImageView()
|
|
|
+ private var hoverTracker: HoverTracker?
|
|
|
+ private var isHovered = false
|
|
|
+
|
|
|
+ init() {
|
|
|
+ super.init(frame: .zero)
|
|
|
+ wantsLayer = true
|
|
|
+ layer?.cornerRadius = 22
|
|
|
+
|
|
|
+ titleLabel.font = AppTheme.semiboldFont(size: 16)
|
|
|
+ titleLabel.textColor = .white
|
|
|
+ titleLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+
|
|
|
+ if let image = NSImage(systemSymbolName: "printer.fill", accessibilityDescription: "Print") {
|
|
|
+ let config = NSImage.SymbolConfiguration(pointSize: 14, weight: .semibold)
|
|
|
+ iconView.image = image.withSymbolConfiguration(config)
|
|
|
+ }
|
|
|
+ iconView.contentTintColor = .white
|
|
|
+ iconView.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+
|
|
|
+ addSubview(titleLabel)
|
|
|
+ addSubview(iconView)
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
+ heightAnchor.constraint(equalToConstant: 52),
|
|
|
+ titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 28),
|
|
|
+ titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
|
|
|
+ iconView.leadingAnchor.constraint(equalTo: titleLabel.trailingAnchor, constant: 10),
|
|
|
+ iconView.centerYAnchor.constraint(equalTo: centerYAnchor),
|
|
|
+ iconView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -28),
|
|
|
+ iconView.widthAnchor.constraint(equalToConstant: 20),
|
|
|
+ iconView.heightAnchor.constraint(equalToConstant: 20),
|
|
|
+ ])
|
|
|
+
|
|
|
+ hoverTracker = HoverTracker(view: self) { [weak self] hovering in
|
|
|
+ self?.setHovered(hovering)
|
|
|
+ }
|
|
|
+ refreshAppearance()
|
|
|
+ }
|
|
|
+
|
|
|
+ @available(*, unavailable)
|
|
|
+ required init?(coder: NSCoder) { nil }
|
|
|
+
|
|
|
+ func refreshAppearance() {
|
|
|
+ let color = isHovered
|
|
|
+ ? AppTheme.blue.blended(withFraction: 0.15, of: .black) ?? AppTheme.blue
|
|
|
+ : AppTheme.blue
|
|
|
+ layer?.backgroundColor = color.cgColor
|
|
|
+ titleLabel.textColor = .white
|
|
|
+ iconView.contentTintColor = .white
|
|
|
+ }
|
|
|
+
|
|
|
+ private func setHovered(_ hovering: Bool) {
|
|
|
+ isHovered = hovering
|
|
|
+ animateHover {
|
|
|
+ let color = hovering
|
|
|
+ ? AppTheme.blue.blended(withFraction: 0.15, of: .black) ?? AppTheme.blue
|
|
|
+ : AppTheme.blue
|
|
|
+ layer?.backgroundColor = color.cgColor
|
|
|
+ layer?.transform = hovering
|
|
|
+ ? CATransform3DMakeScale(1.03, 1.03, 1)
|
|
|
+ : CATransform3DIdentity
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ override func mouseUp(with event: NSEvent) {
|
|
|
+ guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
|
|
|
+ onClick?()
|
|
|
+ }
|
|
|
+
|
|
|
+ override func resetCursorRects() {
|
|
|
+ addCursorRect(bounds, cursor: .pointingHand)
|
|
|
+ }
|
|
|
+}
|