Quellcode durchsuchen

Support multi-document scanning with page selection and full PDF export.

Users can scan several documents in one session, choose which pages to print or save, and export each scan as its own PDF page without duplicates or dropped pages.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 vor 1 Monat
Ursprung
Commit
e776c95246
3 geänderte Dateien mit 522 neuen und 59 gelöschten Zeilen
  1. 31 14
      smart_printer/PrintService.swift
  2. 390 30
      smart_printer/ScanFileView.swift
  3. 101 15
      smart_printer/ScannerOverlayView.swift

+ 31 - 14
smart_printer/PrintService.swift

@@ -395,9 +395,25 @@ enum PrintService {
     private static func pdfDocument(from pages: [ParsedScanPage]) -> PDFDocument? {
         guard !pages.isEmpty else { return nil }
 
+        let combined = PDFDocument()
+        for page in pages {
+            guard let pdfPage = pdfPage(from: page) else { continue }
+            combined.insert(pdfPage, at: combined.pageCount)
+        }
+        return combined.pageCount > 0 ? combined : nil
+    }
+
+    private static func pdfPage(from page: ParsedScanPage) -> PDFPage? {
+        let bodyEmpty = page.body.plainText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+        let captionEmpty = page.caption?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true
+
+        if bodyEmpty && captionEmpty {
+            return PDFPage(image: page.picture)
+        }
+
         let printInfo = configuredPrintInfo()
         let pageSize = printInfo.paperSize
-        guard pageSize.width > 0, pageSize.height > 0 else { return nil }
+        guard pageSize.width > 0, pageSize.height > 0 else { return PDFPage(image: page.picture) }
 
         let contentWidth = pageSize.width - printInfo.leftMargin - printInfo.rightMargin
         let contentHeight = pageSize.height - printInfo.topMargin - printInfo.bottomMargin
@@ -406,22 +422,23 @@ enum PrintService {
         var mediaBox = CGRect(origin: .zero, size: pageSize)
         guard let consumer = CGDataConsumer(data: pdfData as CFMutableData),
               let context = CGContext(consumer: consumer, mediaBox: &mediaBox, nil) else {
-            return nil
-        }
-
-        for page in pages {
-            appendParsedScanPage(
-                page,
-                to: context,
-                pageSize: pageSize,
-                printInfo: printInfo,
-                contentWidth: contentWidth,
-                contentHeight: contentHeight
-            )
+            return PDFPage(image: page.picture)
         }
 
+        appendParsedScanPage(
+            page,
+            to: context,
+            pageSize: pageSize,
+            printInfo: printInfo,
+            contentWidth: contentWidth,
+            contentHeight: contentHeight
+        )
         context.closePDF()
-        return PDFDocument(data: pdfData as Data)
+
+        guard let document = PDFDocument(data: pdfData as Data) else {
+            return PDFPage(image: page.picture)
+        }
+        return document.page(at: 0)
     }
 
     private static func appendParsedScanPage(

+ 390 - 30
smart_printer/ScanFileView.swift

@@ -718,13 +718,116 @@ enum ScanDocumentBuilder {
     }
 }
 
+// MARK: - Page Thumbnail Selector
+
+private final class ScanPageThumbnailView: NSControl, AppearanceRefreshable {
+    var pageIndex = 0
+    var isCurrentPage = false { didSet { refreshAppearance() } }
+    var isSelectedForPrint = true {
+        didSet {
+            checkbox.state = isSelectedForPrint ? .on : .off
+        }
+    }
+
+    var onSelectPage: ((Int) -> Void)?
+    var onTogglePrintSelection: ((Int, Bool) -> Void)?
+
+    private let imageView = NSImageView()
+    private let checkbox = NSButton(checkboxWithTitle: "", target: nil, action: nil)
+    private let indexLabel = NSTextField(labelWithString: "")
+
+    init(image: NSImage, index: Int) {
+        super.init(frame: .zero)
+        pageIndex = index
+        wantsLayer = true
+        layer?.cornerRadius = 8
+        translatesAutoresizingMaskIntoConstraints = false
+
+        imageView.image = image
+        imageView.imageScaling = .scaleProportionallyUpOrDown
+        imageView.translatesAutoresizingMaskIntoConstraints = false
+
+        checkbox.translatesAutoresizingMaskIntoConstraints = false
+        checkbox.state = .on
+        checkbox.target = self
+        checkbox.action = #selector(checkboxChanged)
+
+        indexLabel.stringValue = "\(index + 1)"
+        indexLabel.font = AppTheme.semiboldFont(size: 10)
+        indexLabel.alignment = .center
+        indexLabel.wantsLayer = true
+        indexLabel.layer?.cornerRadius = 8
+        indexLabel.translatesAutoresizingMaskIntoConstraints = false
+
+        addSubview(imageView)
+        addSubview(checkbox)
+        addSubview(indexLabel)
+
+        NSLayoutConstraint.activate([
+            widthAnchor.constraint(equalToConstant: 72),
+            heightAnchor.constraint(equalToConstant: 92),
+
+            imageView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 4),
+            imageView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -4),
+            imageView.topAnchor.constraint(equalTo: topAnchor, constant: 4),
+            imageView.heightAnchor.constraint(equalToConstant: 56),
+
+            checkbox.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 2),
+            checkbox.topAnchor.constraint(equalTo: topAnchor, constant: 0),
+
+            indexLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 4),
+            indexLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -4),
+            indexLabel.topAnchor.constraint(equalTo: imageView.bottomAnchor, constant: 4),
+            indexLabel.heightAnchor.constraint(equalToConstant: 16),
+        ])
+
+        refreshAppearance()
+    }
+
+    @available(*, unavailable)
+    required init?(coder: NSCoder) { nil }
+
+    func refreshAppearance() {
+        layer?.borderWidth = isCurrentPage ? 2 : 1
+        layer?.borderColor = (isCurrentPage ? AppTheme.blue : AppTheme.paywallBorder).cgColor
+        indexLabel.textColor = .white
+        indexLabel.layer?.backgroundColor = (isCurrentPage ? AppTheme.blue : AppTheme.textSecondary).cgColor
+    }
+
+    @objc private func checkboxChanged() {
+        let selected = checkbox.state == .on
+        isSelectedForPrint = selected
+        onTogglePrintSelection?(pageIndex, selected)
+    }
+
+    override func mouseUp(with event: NSEvent) {
+        let point = convert(event.locationInWindow, from: nil)
+        guard bounds.contains(point) else { return }
+        if checkbox.frame.contains(point) { return }
+        onSelectPage?(pageIndex)
+    }
+
+    override func resetCursorRects() {
+        addCursorRect(bounds, cursor: .pointingHand)
+    }
+}
+
 // MARK: - Editable Scan Content
 
 /// Picture on top, editable text below — matching the original document layout.
 final class EditableScanContentView: NSView, AppearanceRefreshable {
     var onRecognitionComplete: (() -> Void)?
 
-    private var parsedPages: [ParsedScanPage] = []
+    private struct PageState {
+        var parsed: ParsedScanPage
+        var fullImage: NSImage
+        var isSelectedForPrint = true
+    }
+
+    private var pageStates: [PageState] = []
+    private var currentPageIndex = 0
+    private var printSelections: [Bool] = []
+    private var pageProcessingTask: Task<Void, Never>?
 
     private let contentScrollView = NSScrollView()
     private let documentStack = NSStackView()
@@ -741,6 +844,16 @@ final class EditableScanContentView: NSView, AppearanceRefreshable {
     private var pictureHeightConstraint: NSLayoutConstraint!
     private let loadingIndicator = NSProgressIndicator()
     private let statusLabel = NSTextField(labelWithString: "Preparing document…")
+    private let pageSelectorContainer = NSView()
+    private let pageSelectorLabel = NSTextField(labelWithString: "")
+    private let pageSelectorScroll = NSScrollView()
+    private let pageSelectorStack = NSStackView()
+    private var pageSelectorHeightConstraint: NSLayoutConstraint!
+    private var contentBottomToPageSelector: NSLayoutConstraint!
+    private var thumbnailViews: [ScanPageThumbnailView] = []
+
+    var pageCount: Int { pageStates.count }
+    var selectedPageCount: Int { printSelections.filter { $0 }.count }
 
     var text: String {
         exportPages().map(\.body.plainText).joined(separator: "\n\n")
@@ -762,57 +875,133 @@ final class EditableScanContentView: NSView, AppearanceRefreshable {
 
     func refreshAppearance() {
         statusLabel.textColor = AppTheme.textSecondary
+        pageSelectorLabel.textColor = AppTheme.textSecondary
         for textView in [captionText, singleColumnText, leftColumnText, rightColumnText] {
             textView.textColor = AppTheme.textPrimary
             textView.font = AppTheme.regularFont(size: 14)
         }
+        thumbnailViews.forEach { $0.refreshAppearance() }
     }
 
     func loadDocument(pages: [ScanPage]) {
         if pages.allSatisfy({ !$0.text.isEmpty }) {
             let parsed = pages.map(ScanDocumentBuilder.parsedPage(from:))
-            showPages(parsed)
+            applyParsedPages(parsed, fullImages: pages.map(\.image), showIndex: 0)
             onRecognitionComplete?()
             return
         }
-        recognizeText(from: pages)
+        enqueuePageProcessing { [weak self] in
+            await self?.recognizeText(from: pages)
+        }
     }
 
     func recognizeText(from images: [NSImage]) {
-        recognizeText(from: images.map { ($0, "") })
+        enqueuePageProcessing { [weak self] in
+            await self?.recognizeText(from: images.map { ($0, "") })
+        }
+    }
+
+    func addPage(_ image: NSImage) {
+        enqueuePageProcessing { [weak self] in
+            await self?.appendParsedPage(from: image)
+        }
     }
 
     func appendAndRecognize(_ newImages: [NSImage], existingPages: [ScanPage]) {
-        let newPages = newImages.map { (image: $0, text: "") }
-        recognizeText(from: existingPages + newPages)
+        guard !newImages.isEmpty else { return }
+        enqueuePageProcessing { [weak self] in
+            await self?.appendRecognizedPages(newImages, existingPages: existingPages)
+        }
+    }
+
+    private func enqueuePageProcessing(_ operation: @escaping @MainActor () async -> Void) {
+        let prior = pageProcessingTask
+        pageProcessingTask = Task { @MainActor in
+            if let prior { await prior.value }
+            await operation()
+        }
+    }
+
+    private func appendParsedPage(from image: NSImage) async {
+        commitCurrentPageEdits()
+        showLoading("Preparing document…")
+        let parsed = await ScanDocumentBuilder.parsePage(from: image)
+        pageStates.append(PageState(parsed: parsed, fullImage: image, isSelectedForPrint: true))
+        printSelections.append(true)
+        applyCurrentPage(at: pageStates.count - 1)
+        rebuildPageSelector()
+        onRecognitionComplete?()
     }
 
-    private func recognizeText(from pages: [ScanPage]) {
+    private func appendRecognizedPages(_ newImages: [NSImage], existingPages: [ScanPage]) async {
+        commitCurrentPageEdits()
         showLoading("Preparing document…")
-        Task { @MainActor in
-            var parsed: [ParsedScanPage] = []
-            for page in pages {
+
+        var parsed = pageStates.map(\.parsed)
+        var fullImages = pageStates.map(\.fullImage)
+        if parsed.isEmpty {
+            for page in existingPages {
                 if !page.text.isEmpty {
                     parsed.append(ScanDocumentBuilder.parsedPage(from: page))
                 } else {
                     parsed.append(await ScanDocumentBuilder.parsePage(from: page.image))
                 }
+                fullImages.append(page.image)
             }
-            showPages(parsed)
-            onRecognitionComplete?()
         }
+        for image in newImages {
+            parsed.append(await ScanDocumentBuilder.parsePage(from: image))
+            fullImages.append(image)
+        }
+        applyParsedPages(parsed, fullImages: fullImages, showIndex: parsed.count - 1)
+        onRecognitionComplete?()
+    }
+
+    private func recognizeText(from pages: [ScanPage]) async {
+        showLoading("Preparing document…")
+        var parsed: [ParsedScanPage] = []
+        for page in pages {
+            if !page.text.isEmpty {
+                parsed.append(ScanDocumentBuilder.parsedPage(from: page))
+            } else {
+                parsed.append(await ScanDocumentBuilder.parsePage(from: page.image))
+            }
+        }
+        applyParsedPages(parsed, fullImages: pages.map(\.image), showIndex: 0)
+        onRecognitionComplete?()
     }
 
     func exportPages() -> [ParsedScanPage] {
-        guard let first = parsedPages.first else { return [] }
-        let body: ScanBodyLayout
-        if !columnRow.isHidden {
-            body = .twoColumn(left: leftColumnText.attributedString(), right: rightColumnText.attributedString())
-        } else {
-            body = .single(singleColumnText.attributedString())
+        commitCurrentPageEdits()
+        return pageStates.map(\.parsed)
+    }
+
+    /// Full scanned images plus any edited text — used for multi-page PDF export and printing.
+    func exportPagesForPrint() -> [ParsedScanPage] {
+        commitCurrentPageEdits()
+        return pageStates.map { state in
+            ParsedScanPage(
+                picture: state.fullImage,
+                caption: state.parsed.caption,
+                body: state.parsed.body
+            )
+        }
+    }
+
+    func exportSelectedPagesForPrint() -> [ParsedScanPage] {
+        commitCurrentPageEdits()
+        return pageStates.enumerated().compactMap { index, state in
+            guard printSelections.indices.contains(index), printSelections[index] else { return nil }
+            return ParsedScanPage(
+                picture: state.fullImage,
+                caption: state.parsed.caption,
+                body: state.parsed.body
+            )
         }
-        let caption = captionScroll.isHidden ? nil : captionText.string.trimmingCharacters(in: .whitespacesAndNewlines)
-        return [ParsedScanPage(picture: first.picture, caption: caption?.isEmpty == false ? caption : nil, body: body)]
+    }
+
+    func exportSelectedPages() -> [ParsedScanPage] {
+        exportSelectedPagesForPrint()
     }
 
     func makeFirstResponder() {
@@ -873,17 +1062,69 @@ final class EditableScanContentView: NSView, AppearanceRefreshable {
         statusLabel.maximumNumberOfLines = 3
         statusLabel.translatesAutoresizingMaskIntoConstraints = false
 
+        pageSelectorContainer.translatesAutoresizingMaskIntoConstraints = false
+        pageSelectorContainer.isHidden = true
+
+        pageSelectorLabel.font = AppTheme.regularFont(size: 12)
+        pageSelectorLabel.textColor = AppTheme.textSecondary
+        pageSelectorLabel.translatesAutoresizingMaskIntoConstraints = false
+
+        pageSelectorScroll.translatesAutoresizingMaskIntoConstraints = false
+        pageSelectorScroll.hasVerticalScroller = false
+        pageSelectorScroll.hasHorizontalScroller = true
+        pageSelectorScroll.autohidesScrollers = true
+        pageSelectorScroll.drawsBackground = false
+        pageSelectorScroll.borderType = .noBorder
+
+        pageSelectorStack.orientation = .horizontal
+        pageSelectorStack.spacing = 8
+        pageSelectorStack.translatesAutoresizingMaskIntoConstraints = false
+
+        let selectorDocument = NSView()
+        selectorDocument.translatesAutoresizingMaskIntoConstraints = false
+        selectorDocument.addSubview(pageSelectorStack)
+        pageSelectorScroll.documentView = selectorDocument
+
+        pageSelectorContainer.addSubview(pageSelectorLabel)
+        pageSelectorContainer.addSubview(pageSelectorScroll)
+
         addSubview(contentScrollView)
+        addSubview(pageSelectorContainer)
         addSubview(loadingIndicator)
         addSubview(statusLabel)
 
         pictureHeightConstraint = pictureView.heightAnchor.constraint(equalToConstant: 200)
+        pageSelectorHeightConstraint = pageSelectorContainer.heightAnchor.constraint(equalToConstant: 0)
+        contentBottomToPageSelector = contentScrollView.bottomAnchor.constraint(
+            equalTo: pageSelectorContainer.topAnchor
+        )
 
         NSLayoutConstraint.activate([
             contentScrollView.leadingAnchor.constraint(equalTo: leadingAnchor),
             contentScrollView.trailingAnchor.constraint(equalTo: trailingAnchor),
             contentScrollView.topAnchor.constraint(equalTo: topAnchor),
-            contentScrollView.bottomAnchor.constraint(equalTo: bottomAnchor),
+            contentBottomToPageSelector,
+
+            pageSelectorContainer.leadingAnchor.constraint(equalTo: leadingAnchor),
+            pageSelectorContainer.trailingAnchor.constraint(equalTo: trailingAnchor),
+            pageSelectorContainer.bottomAnchor.constraint(equalTo: bottomAnchor),
+            pageSelectorHeightConstraint,
+
+            pageSelectorLabel.leadingAnchor.constraint(equalTo: pageSelectorContainer.leadingAnchor),
+            pageSelectorLabel.trailingAnchor.constraint(equalTo: pageSelectorContainer.trailingAnchor),
+            pageSelectorLabel.topAnchor.constraint(equalTo: pageSelectorContainer.topAnchor, constant: 4),
+
+            pageSelectorScroll.leadingAnchor.constraint(equalTo: pageSelectorContainer.leadingAnchor),
+            pageSelectorScroll.trailingAnchor.constraint(equalTo: pageSelectorContainer.trailingAnchor),
+            pageSelectorScroll.topAnchor.constraint(equalTo: pageSelectorLabel.bottomAnchor, constant: 6),
+            pageSelectorScroll.bottomAnchor.constraint(equalTo: pageSelectorContainer.bottomAnchor, constant: -4),
+            pageSelectorScroll.heightAnchor.constraint(equalToConstant: 96),
+
+            pageSelectorStack.leadingAnchor.constraint(equalTo: selectorDocument.leadingAnchor),
+            pageSelectorStack.trailingAnchor.constraint(equalTo: selectorDocument.trailingAnchor),
+            pageSelectorStack.topAnchor.constraint(equalTo: selectorDocument.topAnchor),
+            pageSelectorStack.bottomAnchor.constraint(equalTo: selectorDocument.bottomAnchor),
+            selectorDocument.heightAnchor.constraint(equalTo: pageSelectorScroll.heightAnchor),
 
             documentStack.leadingAnchor.constraint(equalTo: documentContainer.leadingAnchor),
             documentStack.trailingAnchor.constraint(equalTo: documentContainer.trailingAnchor),
@@ -952,15 +1193,31 @@ final class EditableScanContentView: NSView, AppearanceRefreshable {
         loadingIndicator.isHidden = false
         loadingIndicator.startAnimation(nil)
         contentScrollView.isHidden = true
+        pageSelectorContainer.isHidden = true
+        pageSelectorHeightConstraint.constant = 0
     }
 
-    private func showPages(_ pages: [ParsedScanPage]) {
-        parsedPages = pages
-        guard let page = pages.first else { return }
+    private func applyParsedPages(_ pages: [ParsedScanPage], fullImages: [NSImage], showIndex: Int) {
+        let images = fullImages.count == pages.count
+            ? fullImages
+            : pages.map(\.picture)
+        pageStates = zip(pages, images).map { parsed, image in
+            PageState(parsed: parsed, fullImage: image, isSelectedForPrint: true)
+        }
+        printSelections = Array(repeating: true, count: pages.count)
+        applyCurrentPage(at: min(showIndex, max(pages.count - 1, 0)))
+        rebuildPageSelector()
+    }
+
+    private func applyCurrentPage(at index: Int) {
+        guard pageStates.indices.contains(index) else { return }
+        currentPageIndex = index
+        let page = pageStates[index].parsed
 
         loadingIndicator.stopAnimation(nil)
         loadingIndicator.isHidden = true
         statusLabel.isHidden = true
+        contentScrollView.isHidden = false
 
         pictureView.image = page.picture
         updatePictureHeight(for: page.picture)
@@ -985,7 +1242,75 @@ final class EditableScanContentView: NSView, AppearanceRefreshable {
             rightColumnText.textStorage?.setAttributedString(right)
         }
 
-        contentScrollView.isHidden = false
+        refreshThumbnailSelection()
+    }
+
+    private func commitCurrentPageEdits() {
+        guard pageStates.indices.contains(currentPageIndex) else { return }
+
+        let body: ScanBodyLayout
+        if !columnRow.isHidden {
+            body = .twoColumn(left: leftColumnText.attributedString(), right: rightColumnText.attributedString())
+        } else {
+            body = .single(singleColumnText.attributedString())
+        }
+        let caption = captionScroll.isHidden ? nil : captionText.string.trimmingCharacters(in: .whitespacesAndNewlines)
+        pageStates[currentPageIndex].parsed = ParsedScanPage(
+            picture: pageStates[currentPageIndex].parsed.picture,
+            caption: caption?.isEmpty == false ? caption : nil,
+            body: body
+        )
+        pageStates[currentPageIndex].isSelectedForPrint = printSelections.indices.contains(currentPageIndex)
+            ? printSelections[currentPageIndex]
+            : true
+    }
+
+    private func rebuildPageSelector() {
+        thumbnailViews.forEach { $0.removeFromSuperview() }
+        thumbnailViews.removeAll()
+
+        let showSelector = pageStates.count > 1
+        pageSelectorContainer.isHidden = !showSelector
+        pageSelectorHeightConstraint.constant = showSelector ? 118 : 0
+
+        guard showSelector else { return }
+
+        let selectedCount = printSelections.filter { $0 }.count
+        pageSelectorLabel.stringValue = "\(pageStates.count) documents — \(selectedCount) selected for print"
+
+        for (index, state) in pageStates.enumerated() {
+            let thumbnail = ScanPageThumbnailView(image: state.parsed.picture, index: index)
+            thumbnail.isCurrentPage = index == currentPageIndex
+            thumbnail.isSelectedForPrint = printSelections.indices.contains(index) ? printSelections[index] : true
+            thumbnail.onSelectPage = { [weak self] pageIndex in
+                self?.selectPage(at: pageIndex)
+            }
+            thumbnail.onTogglePrintSelection = { [weak self] pageIndex, selected in
+                self?.setPrintSelection(at: pageIndex, selected: selected)
+            }
+            pageSelectorStack.addArrangedSubview(thumbnail)
+            thumbnailViews.append(thumbnail)
+        }
+    }
+
+    private func selectPage(at index: Int) {
+        guard index != currentPageIndex, pageStates.indices.contains(index) else { return }
+        commitCurrentPageEdits()
+        applyCurrentPage(at: index)
+    }
+
+    private func setPrintSelection(at index: Int, selected: Bool) {
+        guard printSelections.indices.contains(index) else { return }
+        printSelections[index] = selected
+        pageStates[index].isSelectedForPrint = selected
+        let selectedCount = printSelections.filter { $0 }.count
+        pageSelectorLabel.stringValue = "\(pageStates.count) documents — \(selectedCount) selected for print"
+    }
+
+    private func refreshThumbnailSelection() {
+        for thumbnail in thumbnailViews {
+            thumbnail.isCurrentPage = thumbnail.pageIndex == currentPageIndex
+        }
     }
 
     private func updatePictureHeight(for image: NSImage) {
@@ -1341,14 +1666,19 @@ final class ScanFileOverlayView: NSView, AppearanceRefreshable {
 
     func appendPages(_ newPages: [ScanPage]) {
         guard !newPages.isEmpty else { return }
-        editorView.appendAndRecognize(newPages.map(\.image), existingPages: pages)
-        pages.append(contentsOf: newPages)
+        if pages.isEmpty {
+            pages = newPages
+            editorView.loadDocument(pages: newPages)
+        } else {
+            editorView.appendAndRecognize(newPages.map(\.image), existingPages: pages)
+            pages.append(contentsOf: newPages)
+        }
         updatePageCount()
     }
 
     private func updatePageCount() {
         if pages.count > 1 {
-            counterLabel.stringValue = "\(pages.count) pages — picture on top, editable text below"
+            counterLabel.stringValue = "\(pages.count) documents — select pages below, then print or save"
         } else {
             counterLabel.stringValue = "Picture on top, editable text below"
         }
@@ -1356,11 +1686,41 @@ final class ScanFileOverlayView: NSView, AppearanceRefreshable {
     }
 
     private func saveAsPDF() {
-        PrintService.saveParsedScansAsPDF(pages: editorView.exportPages(), defaultName: "Scan", from: window)
+        let pages = pagesForExport()
+        guard !pages.isEmpty else {
+            showNoPagesSelectedAlert()
+            return
+        }
+        PrintService.saveParsedScansAsPDF(pages: pages, defaultName: "Scan", from: window)
     }
 
     private func printScan() {
-        PrintService.printParsedScans(pages: editorView.exportPages(), title: "Scan", from: window)
+        let pages = pagesForExport()
+        guard !pages.isEmpty else {
+            showNoPagesSelectedAlert()
+            return
+        }
+        PrintService.printParsedScans(pages: pages, title: "Scan", from: window)
+    }
+
+    private func pagesForExport() -> [ParsedScanPage] {
+        if editorView.pageCount > 0 {
+            return editorView.exportSelectedPagesForPrint()
+        }
+        return editorView.exportPagesForPrint()
+    }
+
+    private func showNoPagesSelectedAlert() {
+        let alert = NSAlert()
+        alert.messageText = "No Documents Selected"
+        alert.informativeText = "Select at least one scanned document using the checkboxes below the preview."
+        alert.alertStyle = .informational
+        alert.addButton(withTitle: "OK")
+        if let window {
+            alert.beginSheetModal(for: window)
+        } else {
+            alert.runModal()
+        }
     }
 }
 

+ 101 - 15
smart_printer/ScannerOverlayView.swift

@@ -18,6 +18,7 @@ final class ScannerOverlayView: NSView, AppearanceRefreshable {
 
     private let backButton = ScannerToolbarButton(symbolName: "chevron.left", accessibilityLabel: "Back")
     private let titleLabel = NSTextField(labelWithString: "Scanner")
+    private let documentsScannedLabel = NSTextField(labelWithString: "")
     private let sidebarView = NSView()
     private let localHeader = ScannerSectionHeader(title: "DEVICES")
     private let localList = NSStackView()
@@ -128,6 +129,7 @@ final class ScannerOverlayView: NSView, AppearanceRefreshable {
     func refreshAppearance() {
         layer?.backgroundColor = AppTheme.background.cgColor
         titleLabel.textColor = AppTheme.textPrimary
+        documentsScannedLabel.textColor = AppTheme.textSecondary
         sidebarView.layer?.backgroundColor = AppTheme.cardBackground.cgColor
         sidebarView.layer?.borderColor = AppTheme.paywallBorder.cgColor
         previewContainer.layer?.backgroundColor = AppTheme.cardBackground.cgColor
@@ -178,6 +180,12 @@ final class ScannerOverlayView: NSView, AppearanceRefreshable {
         titleLabel.alignment = .center
         titleLabel.translatesAutoresizingMaskIntoConstraints = false
 
+        documentsScannedLabel.font = AppTheme.regularFont(size: 12)
+        documentsScannedLabel.textColor = AppTheme.textSecondary
+        documentsScannedLabel.alignment = .center
+        documentsScannedLabel.translatesAutoresizingMaskIntoConstraints = false
+        documentsScannedLabel.isHidden = true
+
         sidebarView.wantsLayer = true
         sidebarView.layer?.cornerRadius = AppTheme.contentPanelCornerRadius
         sidebarView.layer?.borderWidth = 1.5
@@ -286,6 +294,7 @@ final class ScannerOverlayView: NSView, AppearanceRefreshable {
 
         scanPhaseView.addSubview(backButton)
         scanPhaseView.addSubview(titleLabel)
+        scanPhaseView.addSubview(documentsScannedLabel)
         scanPhaseView.addSubview(sidebarView)
         scanPhaseView.addSubview(previewContainer)
         scanPhaseView.addSubview(statusLabel)
@@ -302,8 +311,11 @@ final class ScannerOverlayView: NSView, AppearanceRefreshable {
             titleLabel.centerXAnchor.constraint(equalTo: scanPhaseView.centerXAnchor),
             titleLabel.centerYAnchor.constraint(equalTo: backButton.centerYAnchor),
 
+            documentsScannedLabel.centerXAnchor.constraint(equalTo: scanPhaseView.centerXAnchor),
+            documentsScannedLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 2),
+
             sidebarView.leadingAnchor.constraint(equalTo: scanPhaseView.leadingAnchor, constant: AppTheme.contentPanelInset),
-            sidebarView.topAnchor.constraint(equalTo: backButton.bottomAnchor, constant: 16),
+            sidebarView.topAnchor.constraint(equalTo: documentsScannedLabel.bottomAnchor, constant: 12),
             sidebarView.bottomAnchor.constraint(equalTo: previewContainer.bottomAnchor),
             sidebarView.widthAnchor.constraint(equalToConstant: 180),
 
@@ -360,7 +372,7 @@ final class ScannerOverlayView: NSView, AppearanceRefreshable {
 
             scanButton.trailingAnchor.constraint(equalTo: previewContainer.trailingAnchor),
             scanButton.bottomAnchor.constraint(equalTo: scanPhaseView.bottomAnchor, constant: -16),
-            scanButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 96),
+            scanButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 160),
             scanButton.heightAnchor.constraint(equalToConstant: 32),
         ])
 
@@ -534,7 +546,7 @@ final class ScannerOverlayView: NSView, AppearanceRefreshable {
             scanButton.isEnabled = scanner.selectedDevice != nil
         case .connecting:
             if scanner.isWarmReconnecting {
-                statusLabel.stringValue = hasResult ? "Ready for next scan" : "Preparing scanner…"
+                statusLabel.stringValue = hasResult ? updateScanStatusMessage() : "Preparing scanner…"
                 progressIndicator.stopAnimation(nil)
                 scanButton.isEnabled = scanner.selectedDevice != nil
             } else {
@@ -543,7 +555,7 @@ final class ScannerOverlayView: NSView, AppearanceRefreshable {
                 scanButton.isEnabled = false
             }
         case .ready:
-            statusLabel.stringValue = hasResult ? "Ready for next scan" : "Ready to scan"
+            statusLabel.stringValue = hasResult ? updateScanStatusMessage() : "Ready to scan"
             progressIndicator.stopAnimation(nil)
             scanButton.isEnabled = true
             if pendingScan {
@@ -585,26 +597,51 @@ final class ScannerOverlayView: NSView, AppearanceRefreshable {
     private func processScannedImage(_ image: NSImage) {
         let displayImage = image.normalizedForDisplay()
         hasResult = true
-        scannedPages = [displayImage]
+        scannedPages.append(displayImage)
         placeholderView.isHidden = true
         editorView.isHidden = false
-        resultEditorView.isHidden = false
         setPreviewActionsVisible(true)
-        let pages = scannedPages.map { (image: $0, text: "") }
-        editorView.loadDocument(pages: pages)
-        resultEditorView.loadDocument(pages: pages)
-        statusLabel.stringValue = "Ready for next scan — edit text below, then scan again or save"
+
+        if scannedPages.count == 1 {
+            editorView.loadDocument(pages: [(displayImage, "")])
+        } else {
+            editorView.addPage(displayImage)
+        }
+
+        updateScanUI()
         previewContainer.layoutSubtreeIfNeeded()
-        resultPreviewContainer.layoutSubtreeIfNeeded()
+    }
+
+    private func updateScanUI() {
+        let count = scannedPages.count
+        scanButton.title = count > 0 ? "Scan Next Document" : "Scan"
+
+        if count > 0 {
+            documentsScannedLabel.stringValue = count == 1
+                ? "1 document scanned"
+                : "\(count) documents scanned"
+            documentsScannedLabel.isHidden = false
+        } else {
+            documentsScannedLabel.isHidden = true
+        }
+
+        if count > 1 {
+            statusLabel.stringValue = "Select pages to print below, or tap Scan Next Document to add more"
+        } else if count == 1 {
+            statusLabel.stringValue = "Tap Scan Next Document to add another, or print this one"
+        }
     }
 
     private func handleScanResult(_ result: Result<NSImage, Error>) {
         progressIndicator.stopAnimation(nil)
         switch result {
         case .success(let image):
-            processScannedImage(image)
+            if let onScanComplete {
+                onScanComplete(image)
+            } else {
+                processScannedImage(image)
+            }
             updateStateUI(scanner.state)
-            onScanComplete?(image)
         case .failure(let error):
             if case ScanError.cancelled = error { return }
             statusLabel.stringValue = error.localizedDescription
@@ -616,12 +653,56 @@ final class ScannerOverlayView: NSView, AppearanceRefreshable {
         editorView.isHidden ? resultEditorView : editorView
     }
 
+    private func updateScanStatusMessage() -> String {
+        let count = scannedPages.count
+        if count > 1 {
+            return "Select pages to print below, or tap Scan Next Document to add more"
+        }
+        return "Tap Scan Next Document to add another, or print this one"
+    }
+
     private func saveAsPDF() {
-        PrintService.saveParsedScansAsPDF(pages: activeEditor().exportPages(), defaultName: "Scan", from: window)
+        let pages = pagesForExport()
+        guard !pages.isEmpty else {
+            showNoPagesSelectedAlert()
+            return
+        }
+        PrintService.saveParsedScansAsPDF(pages: pages, defaultName: "Scan", from: window)
     }
 
     private func printScan() {
-        PrintService.printParsedScans(pages: activeEditor().exportPages(), title: "Scan", from: window)
+        let pages = pagesForExport()
+        guard !pages.isEmpty else {
+            showNoPagesSelectedAlert()
+            return
+        }
+        PrintService.printParsedScans(pages: pages, title: "Scan", from: window)
+    }
+
+    private func pagesForExport() -> [ParsedScanPage] {
+        let editor = activeEditor()
+        if editor.pageCount > 0 {
+            return editor.exportSelectedPagesForPrint()
+        }
+        if !scannedPages.isEmpty {
+            return scannedPages.map {
+                ParsedScanPage(picture: $0, caption: nil, body: .single(NSAttributedString()))
+            }
+        }
+        return []
+    }
+
+    private func showNoPagesSelectedAlert() {
+        let alert = NSAlert()
+        alert.messageText = "No Documents Selected"
+        alert.informativeText = "Select at least one scanned document using the checkboxes below the preview."
+        alert.alertStyle = .informational
+        alert.addButton(withTitle: "OK")
+        if let window {
+            alert.beginSheetModal(for: window)
+        } else {
+            alert.runModal()
+        }
     }
 
     @objc private func paperSizeChanged() {
@@ -810,6 +891,11 @@ private final class ScannerPrimaryButton: NSControl, AppearanceRefreshable {
     private let titleLabel = NSTextField(labelWithString: "")
     private var isEnabledState = true
 
+    var title: String {
+        get { titleLabel.stringValue }
+        set { titleLabel.stringValue = newValue }
+    }
+
     override var isEnabled: Bool {
         get { isEnabledState }
         set {