Explorar el Código

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 hace 1 mes
padre
commit
e776c95246

+ 31 - 14
smart_printer/PrintService.swift

@@ -395,9 +395,25 @@ enum PrintService {
     private static func pdfDocument(from pages: [ParsedScanPage]) -> PDFDocument? {
     private static func pdfDocument(from pages: [ParsedScanPage]) -> PDFDocument? {
         guard !pages.isEmpty else { return nil }
         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 printInfo = configuredPrintInfo()
         let pageSize = printInfo.paperSize
         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 contentWidth = pageSize.width - printInfo.leftMargin - printInfo.rightMargin
         let contentHeight = pageSize.height - printInfo.topMargin - printInfo.bottomMargin
         let contentHeight = pageSize.height - printInfo.topMargin - printInfo.bottomMargin
@@ -406,22 +422,23 @@ enum PrintService {
         var mediaBox = CGRect(origin: .zero, size: pageSize)
         var mediaBox = CGRect(origin: .zero, size: pageSize)
         guard let consumer = CGDataConsumer(data: pdfData as CFMutableData),
         guard let consumer = CGDataConsumer(data: pdfData as CFMutableData),
               let context = CGContext(consumer: consumer, mediaBox: &mediaBox, nil) else {
               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()
         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(
     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
 // MARK: - Editable Scan Content
 
 
 /// Picture on top, editable text below — matching the original document layout.
 /// Picture on top, editable text below — matching the original document layout.
 final class EditableScanContentView: NSView, AppearanceRefreshable {
 final class EditableScanContentView: NSView, AppearanceRefreshable {
     var onRecognitionComplete: (() -> Void)?
     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 contentScrollView = NSScrollView()
     private let documentStack = NSStackView()
     private let documentStack = NSStackView()
@@ -741,6 +844,16 @@ final class EditableScanContentView: NSView, AppearanceRefreshable {
     private var pictureHeightConstraint: NSLayoutConstraint!
     private var pictureHeightConstraint: NSLayoutConstraint!
     private let loadingIndicator = NSProgressIndicator()
     private let loadingIndicator = NSProgressIndicator()
     private let statusLabel = NSTextField(labelWithString: "Preparing document…")
     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 {
     var text: String {
         exportPages().map(\.body.plainText).joined(separator: "\n\n")
         exportPages().map(\.body.plainText).joined(separator: "\n\n")
@@ -762,57 +875,133 @@ final class EditableScanContentView: NSView, AppearanceRefreshable {
 
 
     func refreshAppearance() {
     func refreshAppearance() {
         statusLabel.textColor = AppTheme.textSecondary
         statusLabel.textColor = AppTheme.textSecondary
+        pageSelectorLabel.textColor = AppTheme.textSecondary
         for textView in [captionText, singleColumnText, leftColumnText, rightColumnText] {
         for textView in [captionText, singleColumnText, leftColumnText, rightColumnText] {
             textView.textColor = AppTheme.textPrimary
             textView.textColor = AppTheme.textPrimary
             textView.font = AppTheme.regularFont(size: 14)
             textView.font = AppTheme.regularFont(size: 14)
         }
         }
+        thumbnailViews.forEach { $0.refreshAppearance() }
     }
     }
 
 
     func loadDocument(pages: [ScanPage]) {
     func loadDocument(pages: [ScanPage]) {
         if pages.allSatisfy({ !$0.text.isEmpty }) {
         if pages.allSatisfy({ !$0.text.isEmpty }) {
             let parsed = pages.map(ScanDocumentBuilder.parsedPage(from:))
             let parsed = pages.map(ScanDocumentBuilder.parsedPage(from:))
-            showPages(parsed)
+            applyParsedPages(parsed, fullImages: pages.map(\.image), showIndex: 0)
             onRecognitionComplete?()
             onRecognitionComplete?()
             return
             return
         }
         }
-        recognizeText(from: pages)
+        enqueuePageProcessing { [weak self] in
+            await self?.recognizeText(from: pages)
+        }
     }
     }
 
 
     func recognizeText(from images: [NSImage]) {
     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]) {
     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…")
         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 {
                 if !page.text.isEmpty {
                     parsed.append(ScanDocumentBuilder.parsedPage(from: page))
                     parsed.append(ScanDocumentBuilder.parsedPage(from: page))
                 } else {
                 } else {
                     parsed.append(await ScanDocumentBuilder.parsePage(from: page.image))
                     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] {
     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() {
     func makeFirstResponder() {
@@ -873,17 +1062,69 @@ final class EditableScanContentView: NSView, AppearanceRefreshable {
         statusLabel.maximumNumberOfLines = 3
         statusLabel.maximumNumberOfLines = 3
         statusLabel.translatesAutoresizingMaskIntoConstraints = false
         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(contentScrollView)
+        addSubview(pageSelectorContainer)
         addSubview(loadingIndicator)
         addSubview(loadingIndicator)
         addSubview(statusLabel)
         addSubview(statusLabel)
 
 
         pictureHeightConstraint = pictureView.heightAnchor.constraint(equalToConstant: 200)
         pictureHeightConstraint = pictureView.heightAnchor.constraint(equalToConstant: 200)
+        pageSelectorHeightConstraint = pageSelectorContainer.heightAnchor.constraint(equalToConstant: 0)
+        contentBottomToPageSelector = contentScrollView.bottomAnchor.constraint(
+            equalTo: pageSelectorContainer.topAnchor
+        )
 
 
         NSLayoutConstraint.activate([
         NSLayoutConstraint.activate([
             contentScrollView.leadingAnchor.constraint(equalTo: leadingAnchor),
             contentScrollView.leadingAnchor.constraint(equalTo: leadingAnchor),
             contentScrollView.trailingAnchor.constraint(equalTo: trailingAnchor),
             contentScrollView.trailingAnchor.constraint(equalTo: trailingAnchor),
             contentScrollView.topAnchor.constraint(equalTo: topAnchor),
             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.leadingAnchor.constraint(equalTo: documentContainer.leadingAnchor),
             documentStack.trailingAnchor.constraint(equalTo: documentContainer.trailingAnchor),
             documentStack.trailingAnchor.constraint(equalTo: documentContainer.trailingAnchor),
@@ -952,15 +1193,31 @@ final class EditableScanContentView: NSView, AppearanceRefreshable {
         loadingIndicator.isHidden = false
         loadingIndicator.isHidden = false
         loadingIndicator.startAnimation(nil)
         loadingIndicator.startAnimation(nil)
         contentScrollView.isHidden = true
         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.stopAnimation(nil)
         loadingIndicator.isHidden = true
         loadingIndicator.isHidden = true
         statusLabel.isHidden = true
         statusLabel.isHidden = true
+        contentScrollView.isHidden = false
 
 
         pictureView.image = page.picture
         pictureView.image = page.picture
         updatePictureHeight(for: page.picture)
         updatePictureHeight(for: page.picture)
@@ -985,7 +1242,75 @@ final class EditableScanContentView: NSView, AppearanceRefreshable {
             rightColumnText.textStorage?.setAttributedString(right)
             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) {
     private func updatePictureHeight(for image: NSImage) {
@@ -1341,14 +1666,19 @@ final class ScanFileOverlayView: NSView, AppearanceRefreshable {
 
 
     func appendPages(_ newPages: [ScanPage]) {
     func appendPages(_ newPages: [ScanPage]) {
         guard !newPages.isEmpty else { return }
         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()
         updatePageCount()
     }
     }
 
 
     private func updatePageCount() {
     private func updatePageCount() {
         if pages.count > 1 {
         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 {
         } else {
             counterLabel.stringValue = "Picture on top, editable text below"
             counterLabel.stringValue = "Picture on top, editable text below"
         }
         }
@@ -1356,11 +1686,41 @@ final class ScanFileOverlayView: NSView, AppearanceRefreshable {
     }
     }
 
 
     private func saveAsPDF() {
     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() {
     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 backButton = ScannerToolbarButton(symbolName: "chevron.left", accessibilityLabel: "Back")
     private let titleLabel = NSTextField(labelWithString: "Scanner")
     private let titleLabel = NSTextField(labelWithString: "Scanner")
+    private let documentsScannedLabel = NSTextField(labelWithString: "")
     private let sidebarView = NSView()
     private let sidebarView = NSView()
     private let localHeader = ScannerSectionHeader(title: "DEVICES")
     private let localHeader = ScannerSectionHeader(title: "DEVICES")
     private let localList = NSStackView()
     private let localList = NSStackView()
@@ -128,6 +129,7 @@ final class ScannerOverlayView: NSView, AppearanceRefreshable {
     func refreshAppearance() {
     func refreshAppearance() {
         layer?.backgroundColor = AppTheme.background.cgColor
         layer?.backgroundColor = AppTheme.background.cgColor
         titleLabel.textColor = AppTheme.textPrimary
         titleLabel.textColor = AppTheme.textPrimary
+        documentsScannedLabel.textColor = AppTheme.textSecondary
         sidebarView.layer?.backgroundColor = AppTheme.cardBackground.cgColor
         sidebarView.layer?.backgroundColor = AppTheme.cardBackground.cgColor
         sidebarView.layer?.borderColor = AppTheme.paywallBorder.cgColor
         sidebarView.layer?.borderColor = AppTheme.paywallBorder.cgColor
         previewContainer.layer?.backgroundColor = AppTheme.cardBackground.cgColor
         previewContainer.layer?.backgroundColor = AppTheme.cardBackground.cgColor
@@ -178,6 +180,12 @@ final class ScannerOverlayView: NSView, AppearanceRefreshable {
         titleLabel.alignment = .center
         titleLabel.alignment = .center
         titleLabel.translatesAutoresizingMaskIntoConstraints = false
         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.wantsLayer = true
         sidebarView.layer?.cornerRadius = AppTheme.contentPanelCornerRadius
         sidebarView.layer?.cornerRadius = AppTheme.contentPanelCornerRadius
         sidebarView.layer?.borderWidth = 1.5
         sidebarView.layer?.borderWidth = 1.5
@@ -286,6 +294,7 @@ final class ScannerOverlayView: NSView, AppearanceRefreshable {
 
 
         scanPhaseView.addSubview(backButton)
         scanPhaseView.addSubview(backButton)
         scanPhaseView.addSubview(titleLabel)
         scanPhaseView.addSubview(titleLabel)
+        scanPhaseView.addSubview(documentsScannedLabel)
         scanPhaseView.addSubview(sidebarView)
         scanPhaseView.addSubview(sidebarView)
         scanPhaseView.addSubview(previewContainer)
         scanPhaseView.addSubview(previewContainer)
         scanPhaseView.addSubview(statusLabel)
         scanPhaseView.addSubview(statusLabel)
@@ -302,8 +311,11 @@ final class ScannerOverlayView: NSView, AppearanceRefreshable {
             titleLabel.centerXAnchor.constraint(equalTo: scanPhaseView.centerXAnchor),
             titleLabel.centerXAnchor.constraint(equalTo: scanPhaseView.centerXAnchor),
             titleLabel.centerYAnchor.constraint(equalTo: backButton.centerYAnchor),
             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.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.bottomAnchor.constraint(equalTo: previewContainer.bottomAnchor),
             sidebarView.widthAnchor.constraint(equalToConstant: 180),
             sidebarView.widthAnchor.constraint(equalToConstant: 180),
 
 
@@ -360,7 +372,7 @@ final class ScannerOverlayView: NSView, AppearanceRefreshable {
 
 
             scanButton.trailingAnchor.constraint(equalTo: previewContainer.trailingAnchor),
             scanButton.trailingAnchor.constraint(equalTo: previewContainer.trailingAnchor),
             scanButton.bottomAnchor.constraint(equalTo: scanPhaseView.bottomAnchor, constant: -16),
             scanButton.bottomAnchor.constraint(equalTo: scanPhaseView.bottomAnchor, constant: -16),
-            scanButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 96),
+            scanButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 160),
             scanButton.heightAnchor.constraint(equalToConstant: 32),
             scanButton.heightAnchor.constraint(equalToConstant: 32),
         ])
         ])
 
 
@@ -534,7 +546,7 @@ final class ScannerOverlayView: NSView, AppearanceRefreshable {
             scanButton.isEnabled = scanner.selectedDevice != nil
             scanButton.isEnabled = scanner.selectedDevice != nil
         case .connecting:
         case .connecting:
             if scanner.isWarmReconnecting {
             if scanner.isWarmReconnecting {
-                statusLabel.stringValue = hasResult ? "Ready for next scan" : "Preparing scanner…"
+                statusLabel.stringValue = hasResult ? updateScanStatusMessage() : "Preparing scanner…"
                 progressIndicator.stopAnimation(nil)
                 progressIndicator.stopAnimation(nil)
                 scanButton.isEnabled = scanner.selectedDevice != nil
                 scanButton.isEnabled = scanner.selectedDevice != nil
             } else {
             } else {
@@ -543,7 +555,7 @@ final class ScannerOverlayView: NSView, AppearanceRefreshable {
                 scanButton.isEnabled = false
                 scanButton.isEnabled = false
             }
             }
         case .ready:
         case .ready:
-            statusLabel.stringValue = hasResult ? "Ready for next scan" : "Ready to scan"
+            statusLabel.stringValue = hasResult ? updateScanStatusMessage() : "Ready to scan"
             progressIndicator.stopAnimation(nil)
             progressIndicator.stopAnimation(nil)
             scanButton.isEnabled = true
             scanButton.isEnabled = true
             if pendingScan {
             if pendingScan {
@@ -585,26 +597,51 @@ final class ScannerOverlayView: NSView, AppearanceRefreshable {
     private func processScannedImage(_ image: NSImage) {
     private func processScannedImage(_ image: NSImage) {
         let displayImage = image.normalizedForDisplay()
         let displayImage = image.normalizedForDisplay()
         hasResult = true
         hasResult = true
-        scannedPages = [displayImage]
+        scannedPages.append(displayImage)
         placeholderView.isHidden = true
         placeholderView.isHidden = true
         editorView.isHidden = false
         editorView.isHidden = false
-        resultEditorView.isHidden = false
         setPreviewActionsVisible(true)
         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()
         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>) {
     private func handleScanResult(_ result: Result<NSImage, Error>) {
         progressIndicator.stopAnimation(nil)
         progressIndicator.stopAnimation(nil)
         switch result {
         switch result {
         case .success(let image):
         case .success(let image):
-            processScannedImage(image)
+            if let onScanComplete {
+                onScanComplete(image)
+            } else {
+                processScannedImage(image)
+            }
             updateStateUI(scanner.state)
             updateStateUI(scanner.state)
-            onScanComplete?(image)
         case .failure(let error):
         case .failure(let error):
             if case ScanError.cancelled = error { return }
             if case ScanError.cancelled = error { return }
             statusLabel.stringValue = error.localizedDescription
             statusLabel.stringValue = error.localizedDescription
@@ -616,12 +653,56 @@ final class ScannerOverlayView: NSView, AppearanceRefreshable {
         editorView.isHidden ? resultEditorView : editorView
         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() {
     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() {
     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() {
     @objc private func paperSizeChanged() {
@@ -810,6 +891,11 @@ private final class ScannerPrimaryButton: NSControl, AppearanceRefreshable {
     private let titleLabel = NSTextField(labelWithString: "")
     private let titleLabel = NSTextField(labelWithString: "")
     private var isEnabledState = true
     private var isEnabledState = true
 
 
+    var title: String {
+        get { titleLabel.stringValue }
+        set { titleLabel.stringValue = newValue }
+    }
+
     override var isEnabled: Bool {
     override var isEnabled: Bool {
         get { isEnabledState }
         get { isEnabledState }
         set {
         set {