Преглед изворни кода

Add Scan File flow with scanner import, preview, and print.

Wire the Scan File cards to a new overlay that supports hardware scanning, image import with document detection, multi-page preview, save, and print without resizing the app window.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 пре 1 месец
родитељ
комит
5f608b89aa

+ 2 - 0
smart_printer.xcodeproj/project.pbxproj

@@ -258,6 +258,7 @@
 				ENABLE_USER_SELECTED_FILES = readonly;
 				GENERATE_INFOPLIST_FILE = YES;
 				INFOPLIST_KEY_NSContactsUsageDescription = "Smart Printer needs access to your contacts so you can search and print them.";
+				INFOPLIST_KEY_NSCameraUsageDescription = "Smart Printer needs camera access to scan documents from connected devices.";
 				INFOPLIST_KEY_NSHumanReadableCopyright = "";
 				INFOPLIST_KEY_NSMainStoryboardFile = Main;
 				INFOPLIST_KEY_NSPrincipalClass = NSApplication;
@@ -291,6 +292,7 @@
 				ENABLE_USER_SELECTED_FILES = readonly;
 				GENERATE_INFOPLIST_FILE = YES;
 				INFOPLIST_KEY_NSContactsUsageDescription = "Smart Printer needs access to your contacts so you can search and print them.";
+				INFOPLIST_KEY_NSCameraUsageDescription = "Smart Printer needs camera access to scan documents from connected devices.";
 				INFOPLIST_KEY_NSHumanReadableCopyright = "";
 				INFOPLIST_KEY_NSMainStoryboardFile = Main;
 				INFOPLIST_KEY_NSPrincipalClass = NSApplication;

+ 959 - 0
smart_printer/ScanFileView.swift

@@ -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)
+    }
+}

+ 35 - 0
smart_printer/ViewController.swift

@@ -21,6 +21,7 @@ class ViewController: NSViewController {
     private var printContactsOverlay: PrintContactsOverlayView?
     private var drawPrintOverlay: DrawPrintOverlayView?
     private var ocrFileOverlay: OCRFileOverlayView?
+    private var scanFileOverlay: ScanFileOverlayView?
 
     private var headerView: NSView!
     private var contentTopBelowHeader: NSLayoutConstraint!
@@ -88,6 +89,7 @@ class ViewController: NSViewController {
         printContactsOverlay?.refreshAppearance()
         drawPrintOverlay?.refreshAppearance()
         ocrFileOverlay?.refreshAppearance()
+        scanFileOverlay?.refreshAppearance()
     }
 
     func presentPrintText() {
@@ -96,6 +98,7 @@ class ViewController: NSViewController {
         printContactsOverlay?.dismiss(animated: false)
         drawPrintOverlay?.dismiss(animated: false)
         ocrFileOverlay?.dismiss(animated: false)
+        scanFileOverlay?.dismiss(animated: false)
 
         let overlay = PrintTextOverlayView()
         overlay.onDismiss = { [weak self] in
@@ -111,6 +114,7 @@ class ViewController: NSViewController {
         printTextOverlay?.dismiss(animated: false)
         drawPrintOverlay?.dismiss(animated: false)
         ocrFileOverlay?.dismiss(animated: false)
+        scanFileOverlay?.dismiss(animated: false)
 
         if let overlay = printContactsOverlay {
             overlay.present(in: mainContentView)
@@ -128,6 +132,7 @@ class ViewController: NSViewController {
         printTextOverlay?.dismiss(animated: false)
         printContactsOverlay?.dismiss(animated: false)
         drawPrintOverlay?.dismiss(animated: false)
+        scanFileOverlay?.dismiss(animated: false)
 
         let overlay = OCRFileOverlayView(imageURL: imageURL)
         overlay.onDismiss = { [weak self] in
@@ -137,12 +142,29 @@ class ViewController: NSViewController {
         overlay.present(in: mainContentView)
     }
 
+    func presentScanFile(pages: [NSImage]) {
+        photoPreviewOverlay?.dismiss(animated: false)
+        filePreviewOverlay?.dismiss(animated: false)
+        printTextOverlay?.dismiss(animated: false)
+        printContactsOverlay?.dismiss(animated: false)
+        drawPrintOverlay?.dismiss(animated: false)
+        ocrFileOverlay?.dismiss(animated: false)
+
+        let overlay = ScanFileOverlayView(pages: pages)
+        overlay.onDismiss = { [weak self] in
+            self?.scanFileOverlay = nil
+        }
+        scanFileOverlay = overlay
+        overlay.present(in: mainContentView)
+    }
+
     func presentDrawPrint() {
         photoPreviewOverlay?.dismiss(animated: false)
         filePreviewOverlay?.dismiss(animated: false)
         printTextOverlay?.dismiss(animated: false)
         printContactsOverlay?.dismiss(animated: false)
         ocrFileOverlay?.dismiss(animated: false)
+        scanFileOverlay?.dismiss(animated: false)
 
         let overlay = DrawPrintOverlayView()
         overlay.onDismiss = { [weak self] in
@@ -156,6 +178,7 @@ class ViewController: NSViewController {
         photoPreviewOverlay?.dismiss(animated: false)
         filePreviewOverlay?.dismiss(animated: false)
         ocrFileOverlay?.dismiss(animated: false)
+        scanFileOverlay?.dismiss(animated: false)
 
         let overlay = PhotoPreviewOverlayView(urls: urls)
         overlay.onDismiss = { [weak self] in
@@ -169,6 +192,7 @@ class ViewController: NSViewController {
         photoPreviewOverlay?.dismiss(animated: false)
         filePreviewOverlay?.dismiss(animated: false)
         ocrFileOverlay?.dismiss(animated: false)
+        scanFileOverlay?.dismiss(animated: false)
 
         let overlay = FilePreviewOverlayView(urls: urls)
         overlay.onDismiss = { [weak self] in
@@ -261,6 +285,7 @@ class ViewController: NSViewController {
         printContactsOverlay?.dismiss(animated: false)
         drawPrintOverlay?.dismiss(animated: false)
         ocrFileOverlay?.dismiss(animated: false)
+        scanFileOverlay?.dismiss(animated: false)
     }
 
     private func showDestination(_ destination: SidebarDestination) {
@@ -671,6 +696,16 @@ class ViewController: NSViewController {
                 updated.onActivate = { [weak self] in
                     OCRFileService.present(from: self?.view.window)
                 }
+            } else if card.iconKind == .scanFile {
+                if card.title == "From Photos" {
+                    updated.onActivate = { [weak self] in
+                        DocumentPickerService.present(.photos, from: self?.view.window)
+                    }
+                } else {
+                    updated.onActivate = { [weak self] in
+                        ScanFileService.present(from: self?.view.window)
+                    }
+                }
             }
             return updated
         }

+ 6 - 0
smart_printer/smart_printer.entitlements

@@ -12,5 +12,11 @@
 	<true/>
 	<key>com.apple.security.personal-information.addressbook</key>
 	<true/>
+	<key>com.apple.security.device.scanner</key>
+	<true/>
+	<key>com.apple.security.device.usb</key>
+	<true/>
+	<key>com.apple.security.device.camera</key>
+	<true/>
 </dict>
 </plist>