Forráskód Böngészése

Add editable OCR text workflow for scanned documents.

Scans now extract recognized text into an editor so users can revise content before saving as PDF or printing.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 1 hónapja
szülő
commit
b83e6d9c91

+ 22 - 1
smart_printer/OCRFileView.swift

@@ -66,7 +66,28 @@ enum OCRFileService {
         return try await recognizeText(in: cgImage)
     }
 
-    private static func recognizeText(in cgImage: CGImage) async throws -> String {
+    static func recognizeText(from image: NSImage) async throws -> String {
+        guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
+            throw OCRError.invalidImage
+        }
+        return try await recognizeText(in: cgImage)
+    }
+
+    /// Recognizes text from multiple scan pages and joins them with blank lines between pages.
+    static func recognizeText(from images: [NSImage]) async -> String {
+        var pageTexts: [String] = []
+        for image in images {
+            if let text = try? await recognizeText(from: image) {
+                let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
+                if !trimmed.isEmpty {
+                    pageTexts.append(trimmed)
+                }
+            }
+        }
+        return pageTexts.joined(separator: "\n\n")
+    }
+
+    static func recognizeText(in cgImage: CGImage) async throws -> String {
         try await withCheckedThrowingContinuation { continuation in
             let request = VNRecognizeTextRequest { request, error in
                 if let error {

+ 30 - 0
smart_printer/PrintService.swift

@@ -70,6 +70,36 @@ enum PrintService {
         printPDF(document, title: title, from: window ?? NSApp.keyWindow)
     }
 
+    static func saveTextAsPDF(
+        _ text: String,
+        defaultName: String = "Scan",
+        from window: NSWindow? = nil
+    ) {
+        let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !trimmed.isEmpty else {
+            showEmptyTextAlert()
+            return
+        }
+        guard let document = pdfDocument(from: trimmed) else {
+            showSaveFailedAlert()
+            return
+        }
+
+        let panel = NSSavePanel()
+        panel.title = "Save as PDF"
+        panel.message = "Choose where to save your document."
+        panel.prompt = "Save"
+        panel.nameFieldStringValue = "\(defaultName).pdf"
+        panel.allowedContentTypes = [.pdf]
+        panel.canCreateDirectories = true
+
+        guard panel.runModal() == .OK, let url = panel.url else { return }
+        guard document.write(to: url) else {
+            showSaveFailedAlert()
+            return
+        }
+    }
+
     static func print(urls: [URL], from window: NSWindow? = nil) {
         guard !urls.isEmpty else { return }
 

+ 173 - 43
smart_printer/ScanFileView.swift

@@ -212,6 +212,129 @@ final class ScanPreviewImageView: NSImageView {
     }
 }
 
+// MARK: - Editable Scan Content
+
+/// Runs OCR on scanned pages and presents the recognized text in an editable editor.
+final class EditableScanContentView: NSView, AppearanceRefreshable {
+    var onRecognitionComplete: (() -> Void)?
+
+    private let textScrollView = NSScrollView()
+    private let textView = NSTextView()
+    private let loadingIndicator = NSProgressIndicator()
+    private let statusLabel = NSTextField(labelWithString: "Extracting text…")
+
+    var text: String {
+        get { textView.string }
+        set { textView.string = newValue }
+    }
+
+    override init(frame frameRect: NSRect) {
+        super.init(frame: frameRect)
+        translatesAutoresizingMaskIntoConstraints = false
+        setup()
+        refreshAppearance()
+    }
+
+    @available(*, unavailable)
+    required init?(coder: NSCoder) { nil }
+
+    func refreshAppearance() {
+        statusLabel.textColor = AppTheme.textSecondary
+        textView.textColor = AppTheme.textPrimary
+        textView.font = AppTheme.regularFont(size: 14)
+    }
+
+    func recognizeText(from images: [NSImage]) {
+        showLoading("Extracting text…")
+        Task { @MainActor in
+            let recognized = await OCRFileService.recognizeText(from: images)
+            showRecognizedText(recognized)
+            onRecognitionComplete?()
+        }
+    }
+
+    func appendAndRecognize(_ newImages: [NSImage], existingImages: [NSImage]) {
+        showLoading("Extracting text from new pages…")
+        Task { @MainActor in
+            let recognized = await OCRFileService.recognizeText(from: existingImages + newImages)
+            showRecognizedText(recognized)
+            onRecognitionComplete?()
+        }
+    }
+
+    func makeFirstResponder() {
+        window?.makeFirstResponder(textView)
+    }
+
+    private func setup() {
+        textScrollView.translatesAutoresizingMaskIntoConstraints = false
+        textScrollView.hasVerticalScroller = true
+        textScrollView.hasHorizontalScroller = false
+        textScrollView.autohidesScrollers = true
+        textScrollView.borderType = .noBorder
+        textScrollView.drawsBackground = false
+        textScrollView.isHidden = true
+
+        textView.isRichText = false
+        textView.isEditable = true
+        textView.isSelectable = true
+        textView.drawsBackground = false
+        textView.textContainerInset = NSSize(width: 8, height: 8)
+        textView.isAutomaticQuoteSubstitutionEnabled = true
+        textView.isAutomaticDashSubstitutionEnabled = true
+        textView.isAutomaticTextReplacementEnabled = true
+        textView.isHidden = true
+
+        textScrollView.documentView = textView
+
+        loadingIndicator.style = .spinning
+        loadingIndicator.controlSize = .regular
+        loadingIndicator.translatesAutoresizingMaskIntoConstraints = false
+
+        statusLabel.font = AppTheme.regularFont(size: 14)
+        statusLabel.alignment = .center
+        statusLabel.lineBreakMode = .byWordWrapping
+        statusLabel.maximumNumberOfLines = 3
+        statusLabel.translatesAutoresizingMaskIntoConstraints = false
+
+        addSubview(textScrollView)
+        addSubview(loadingIndicator)
+        addSubview(statusLabel)
+
+        NSLayoutConstraint.activate([
+            textScrollView.leadingAnchor.constraint(equalTo: leadingAnchor),
+            textScrollView.trailingAnchor.constraint(equalTo: trailingAnchor),
+            textScrollView.topAnchor.constraint(equalTo: topAnchor),
+            textScrollView.bottomAnchor.constraint(equalTo: bottomAnchor),
+
+            loadingIndicator.centerXAnchor.constraint(equalTo: centerXAnchor),
+            loadingIndicator.centerYAnchor.constraint(equalTo: centerYAnchor, constant: -12),
+
+            statusLabel.topAnchor.constraint(equalTo: loadingIndicator.bottomAnchor, constant: 12),
+            statusLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16),
+            statusLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16),
+        ])
+    }
+
+    private func showLoading(_ message: String) {
+        statusLabel.stringValue = message
+        statusLabel.isHidden = false
+        loadingIndicator.isHidden = false
+        loadingIndicator.startAnimation(nil)
+        textScrollView.isHidden = true
+        textView.isHidden = true
+    }
+
+    private func showRecognizedText(_ text: String) {
+        loadingIndicator.stopAnimation(nil)
+        loadingIndicator.isHidden = true
+        statusLabel.isHidden = true
+        textView.string = text
+        textView.isHidden = false
+        textScrollView.isHidden = false
+    }
+}
+
 extension NSImage {
     func normalizedForDisplay() -> NSImage {
         guard let cgImage = cgImage(forProposedRect: nil, context: nil, hints: nil) else { return self }
@@ -356,14 +479,15 @@ 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 = ScanPreviewImageView()
-    private let printButton = ScanFilePrintButton()
+    private let editorView = EditableScanContentView()
+    private let actionRow = NSStackView()
+    private let savePDFButton = ScanFileSecondaryButton(title: "Save as PDF", symbolName: "square.and.arrow.down")
+    private let printButton = ScanFileSecondaryButton(title: "Print", symbolName: "printer.fill")
 
     init(pages: [NSImage]) {
         self.pages = pages
@@ -371,7 +495,8 @@ final class ScanFileOverlayView: NSView, AppearanceRefreshable {
         translatesAutoresizingMaskIntoConstraints = false
         setup()
         refreshAppearance()
-        showPage(at: 0)
+        updatePageCount()
+        editorView.recognizeText(from: pages)
         NotificationCenter.default.addObserver(
             self,
             selector: #selector(appearanceDidChange),
@@ -398,7 +523,9 @@ final class ScanFileOverlayView: NSView, AppearanceRefreshable {
         previewContainer.layer?.backgroundColor = AppTheme.cardBackground.cgColor
         previewContainer.layer?.borderColor = AppTheme.paywallBorder.cgColor
         backButton.refreshAppearance()
+        savePDFButton.refreshAppearance()
         printButton.refreshAppearance()
+        editorView.refreshAppearance()
     }
 
     func present(in parent: NSView) {
@@ -453,27 +580,32 @@ final class ScanFileOverlayView: NSView, AppearanceRefreshable {
         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)
+        editorView.translatesAutoresizingMaskIntoConstraints = false
+        editorView.onRecognitionComplete = { [weak self] in
+            self?.editorView.makeFirstResponder()
+        }
+
+        actionRow.orientation = .horizontal
+        actionRow.spacing = 12
+        actionRow.distribution = .fillEqually
+        actionRow.translatesAutoresizingMaskIntoConstraints = false
+        actionRow.addArrangedSubview(savePDFButton)
+        actionRow.addArrangedSubview(printButton)
 
         backButton.translatesAutoresizingMaskIntoConstraints = false
+        savePDFButton.translatesAutoresizingMaskIntoConstraints = false
         printButton.translatesAutoresizingMaskIntoConstraints = false
 
         backButton.onClick = { [weak self] in self?.dismiss() }
+        savePDFButton.onClick = { [weak self] in self?.saveAsPDF() }
         printButton.onClick = { [weak self] in self?.printScan() }
 
-        previewContainer.addSubview(imageView)
+        previewContainer.addSubview(editorView)
         addSubview(backButton)
         addSubview(titleLabel)
         addSubview(counterLabel)
         addSubview(previewContainer)
-
-        addSubview(printButton)
+        addSubview(actionRow)
 
         NSLayoutConstraint.activate([
             backButton.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 24),
@@ -490,45 +622,43 @@ final class ScanFileOverlayView: NSView, AppearanceRefreshable {
             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: printButton.topAnchor, constant: -24),
-
-            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),
-
-            printButton.centerXAnchor.constraint(equalTo: centerXAnchor),
-            printButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -28),
-            printButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 220),
+            previewContainer.bottomAnchor.constraint(equalTo: actionRow.topAnchor, constant: -24),
+
+            editorView.leadingAnchor.constraint(equalTo: previewContainer.leadingAnchor, constant: 12),
+            editorView.trailingAnchor.constraint(equalTo: previewContainer.trailingAnchor, constant: -12),
+            editorView.topAnchor.constraint(equalTo: previewContainer.topAnchor, constant: 12),
+            editorView.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: bottomAnchor, constant: -28),
+            savePDFButton.heightAnchor.constraint(equalToConstant: 52),
+            printButton.heightAnchor.constraint(equalToConstant: 52),
         ])
     }
 
     func appendPages(_ newPages: [NSImage]) {
         guard !newPages.isEmpty else { return }
+        editorView.appendAndRecognize(newPages, existingImages: pages)
         pages.append(contentsOf: newPages)
-        showPage(at: pages.count - 1)
+        updatePageCount()
     }
 
-    private func showPage(at index: Int) {
-        guard pages.indices.contains(index) else { return }
-        currentIndex = index
-        imageView.image = pages[index].normalizedForDisplay()
-        imageView.invalidateIntrinsicContentSize()
-        previewContainer.layoutSubtreeIfNeeded()
+    private func updatePageCount() {
         if pages.count > 1 {
-            counterLabel.stringValue = "Page \(index + 1) of \(pages.count)"
+            counterLabel.stringValue = "\(pages.count) pages — edit recognized text below"
         } else {
-            counterLabel.stringValue = "1 page"
+            counterLabel.stringValue = "Edit recognized text below"
         }
         counterLabel.isHidden = false
     }
 
+    private func saveAsPDF() {
+        PrintService.saveTextAsPDF(editorView.text, defaultName: "Scan", from: window)
+    }
+
     private func printScan() {
-        guard !pages.isEmpty else { return }
-        for (index, page) in pages.enumerated() {
-            let title = pages.count > 1 ? "Scan Page \(index + 1)" : "Scan"
-            PrintService.printPhoto(page, title: title, from: window)
-        }
+        PrintService.printText(editorView.text, title: "Scan", from: window)
     }
 }
 
@@ -579,24 +709,25 @@ private final class ScanFileToolbarButton: NSControl, AppearanceRefreshable {
     }
 }
 
-private final class ScanFilePrintButton: NSControl, AppearanceRefreshable {
+private final class ScanFileSecondaryButton: NSControl, AppearanceRefreshable {
     var onClick: (() -> Void)?
 
-    private let titleLabel = NSTextField(labelWithString: "Print Scan")
+    private let titleLabel = NSTextField(labelWithString: "")
     private let iconView = NSImageView()
     private var hoverTracker: HoverTracker?
     private var isHovered = false
 
-    init() {
+    init(title: String, symbolName: String) {
         super.init(frame: .zero)
         wantsLayer = true
         layer?.cornerRadius = 22
 
+        titleLabel.stringValue = title
         titleLabel.font = AppTheme.semiboldFont(size: 16)
         titleLabel.textColor = .white
         titleLabel.translatesAutoresizingMaskIntoConstraints = false
 
-        if let image = NSImage(systemSymbolName: "printer.fill", accessibilityDescription: "Print") {
+        if let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: title) {
             let config = NSImage.SymbolConfiguration(pointSize: 14, weight: .semibold)
             iconView.image = image.withSymbolConfiguration(config)
         }
@@ -606,7 +737,6 @@ private final class ScanFilePrintButton: NSControl, AppearanceRefreshable {
         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),

+ 37 - 43
smart_printer/ScannerOverlayView.swift

@@ -11,7 +11,7 @@ final class ScannerOverlayView: NSView, AppearanceRefreshable {
 
     private let scanner: ScannerService
     private var pendingScan = false
-    private var scannedImage: NSImage?
+    private var scannedPages: [NSImage] = []
 
     private let scanPhaseView = NSView()
     private let resultPhaseView = NSView()
@@ -25,7 +25,7 @@ final class ScannerOverlayView: NSView, AppearanceRefreshable {
     private let sharedList = NSStackView()
     private let previewContainer = NSView()
     private let previewImageArea = NSView()
-    private let previewImageView = ScanPreviewImageView()
+    private let editorView = EditableScanContentView()
     private let placeholderView = ScannerPlaceholderView()
     private let previewActionRow = NSStackView()
     private var previewImageAreaBottomToContainer: NSLayoutConstraint!
@@ -37,7 +37,7 @@ final class ScannerOverlayView: NSView, AppearanceRefreshable {
     private let progressIndicator = NSProgressIndicator()
 
     private let resultPreviewContainer = NSView()
-    private let resultImageView = ScanPreviewImageView()
+    private let resultEditorView = EditableScanContentView()
     private let savePDFButton = ScannerSecondaryButton(title: "Save as PDF", symbolName: "square.and.arrow.down")
     private let printButton = ScannerSecondaryButton(title: "Print", symbolName: "printer.fill")
 
@@ -84,13 +84,13 @@ final class ScannerOverlayView: NSView, AppearanceRefreshable {
 
     func showResult(_ image: NSImage) {
         hasResult = true
-        displayScannedImage(image)
+        processScannedImage(image)
         scanPhaseView.isHidden = true
         resultPhaseView.isHidden = false
     }
 
     func updateScanPreview(_ image: NSImage) {
-        displayScannedImage(image)
+        processScannedImage(image)
     }
 
     func present(in parent: NSView) {
@@ -206,14 +206,11 @@ final class ScannerOverlayView: NSView, AppearanceRefreshable {
 
         previewImageArea.translatesAutoresizingMaskIntoConstraints = false
 
-        previewImageView.imageScaling = .scaleProportionallyUpOrDown
-        previewImageView.imageAlignment = .alignCenter
-        previewImageView.translatesAutoresizingMaskIntoConstraints = false
-        previewImageView.isHidden = true
-        previewImageView.setContentHuggingPriority(.defaultLow, for: .horizontal)
-        previewImageView.setContentHuggingPriority(.defaultLow, for: .vertical)
-        previewImageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
-        previewImageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
+        editorView.translatesAutoresizingMaskIntoConstraints = false
+        editorView.isHidden = true
+        editorView.onRecognitionComplete = { [weak self] in
+            self?.editorView.makeFirstResponder()
+        }
 
         placeholderView.translatesAutoresizingMaskIntoConstraints = false
 
@@ -282,7 +279,7 @@ final class ScannerOverlayView: NSView, AppearanceRefreshable {
         sidebarScroll.documentView = sidebarDocument
         sidebarView.addSubview(sidebarScroll)
 
-        previewImageArea.addSubview(previewImageView)
+        previewImageArea.addSubview(editorView)
         previewImageArea.addSubview(placeholderView)
         previewContainer.addSubview(previewImageArea)
         previewContainer.addSubview(previewActionRow)
@@ -345,10 +342,10 @@ final class ScannerOverlayView: NSView, AppearanceRefreshable {
             placeholderView.widthAnchor.constraint(equalTo: previewImageArea.widthAnchor, multiplier: 0.42),
             placeholderView.heightAnchor.constraint(equalTo: placeholderView.widthAnchor),
 
-            previewImageView.leadingAnchor.constraint(equalTo: previewImageArea.leadingAnchor, constant: 20),
-            previewImageView.trailingAnchor.constraint(equalTo: previewImageArea.trailingAnchor, constant: -20),
-            previewImageView.topAnchor.constraint(equalTo: previewImageArea.topAnchor, constant: 20),
-            previewImageView.bottomAnchor.constraint(equalTo: previewImageArea.bottomAnchor, constant: -20),
+            editorView.leadingAnchor.constraint(equalTo: previewImageArea.leadingAnchor),
+            editorView.trailingAnchor.constraint(equalTo: previewImageArea.trailingAnchor),
+            editorView.topAnchor.constraint(equalTo: previewImageArea.topAnchor),
+            editorView.bottomAnchor.constraint(equalTo: previewImageArea.bottomAnchor),
 
             statusLabel.leadingAnchor.constraint(equalTo: previewContainer.leadingAnchor),
             statusLabel.trailingAnchor.constraint(equalTo: previewContainer.trailingAnchor),
@@ -405,13 +402,10 @@ final class ScannerOverlayView: NSView, AppearanceRefreshable {
         resultPreviewContainer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
         resultPreviewContainer.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
 
-        resultImageView.imageScaling = .scaleProportionallyUpOrDown
-        resultImageView.imageAlignment = .alignCenter
-        resultImageView.translatesAutoresizingMaskIntoConstraints = false
-        resultImageView.setContentHuggingPriority(.defaultLow, for: .horizontal)
-        resultImageView.setContentHuggingPriority(.defaultLow, for: .vertical)
-        resultImageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
-        resultImageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
+        resultEditorView.translatesAutoresizingMaskIntoConstraints = false
+        resultEditorView.onRecognitionComplete = { [weak self] in
+            self?.resultEditorView.makeFirstResponder()
+        }
 
         let resultSavePDFButton = ScannerSecondaryButton(title: "Save as PDF", symbolName: "square.and.arrow.down")
         let resultPrintButton = ScannerSecondaryButton(title: "Print", symbolName: "printer.fill")
@@ -426,7 +420,7 @@ final class ScannerOverlayView: NSView, AppearanceRefreshable {
         actionRow.setContentHuggingPriority(.required, for: .vertical)
         actionRow.setContentCompressionResistancePriority(.required, for: .vertical)
 
-        resultPreviewContainer.addSubview(resultImageView)
+        resultPreviewContainer.addSubview(resultEditorView)
         resultPhaseView.addSubview(resultBackButton)
         resultPhaseView.addSubview(resultTitle)
         resultPhaseView.addSubview(resultPreviewContainer)
@@ -446,10 +440,10 @@ final class ScannerOverlayView: NSView, AppearanceRefreshable {
             resultPreviewContainer.topAnchor.constraint(equalTo: resultBackButton.bottomAnchor, constant: 28),
             resultPreviewContainer.bottomAnchor.constraint(equalTo: actionRow.topAnchor, constant: -16),
 
-            resultImageView.leadingAnchor.constraint(equalTo: resultPreviewContainer.leadingAnchor, constant: 12),
-            resultImageView.trailingAnchor.constraint(equalTo: resultPreviewContainer.trailingAnchor, constant: -12),
-            resultImageView.topAnchor.constraint(equalTo: resultPreviewContainer.topAnchor, constant: 12),
-            resultImageView.bottomAnchor.constraint(equalTo: resultPreviewContainer.bottomAnchor, constant: -12),
+            resultEditorView.leadingAnchor.constraint(equalTo: resultPreviewContainer.leadingAnchor, constant: 12),
+            resultEditorView.trailingAnchor.constraint(equalTo: resultPreviewContainer.trailingAnchor, constant: -12),
+            resultEditorView.topAnchor.constraint(equalTo: resultPreviewContainer.topAnchor, constant: 12),
+            resultEditorView.bottomAnchor.constraint(equalTo: resultPreviewContainer.bottomAnchor, constant: -12),
 
             actionRow.leadingAnchor.constraint(equalTo: resultPhaseView.leadingAnchor, constant: AppTheme.contentPanelInset),
             actionRow.trailingAnchor.constraint(equalTo: resultPhaseView.trailingAnchor, constant: -AppTheme.contentPanelInset),
@@ -563,7 +557,7 @@ final class ScannerOverlayView: NSView, AppearanceRefreshable {
 
     private func startScan() {
         guard scanner.selectedDevice != nil else { return }
-        previewImageView.isHidden = true
+        editorView.isHidden = true
         placeholderView.isHidden = false
         setPreviewActionsVisible(false)
 
@@ -582,17 +576,17 @@ final class ScannerOverlayView: NSView, AppearanceRefreshable {
         previewContainer.layoutSubtreeIfNeeded()
     }
 
-    private func displayScannedImage(_ image: NSImage) {
+    private func processScannedImage(_ image: NSImage) {
         let displayImage = image.normalizedForDisplay()
         hasResult = true
-        scannedImage = displayImage
-        previewImageView.image = displayImage
-        previewImageView.isHidden = false
+        scannedPages = [displayImage]
         placeholderView.isHidden = true
+        editorView.isHidden = false
+        resultEditorView.isHidden = false
         setPreviewActionsVisible(true)
-        resultImageView.image = displayImage
-        previewImageView.invalidateIntrinsicContentSize()
-        resultImageView.invalidateIntrinsicContentSize()
+        editorView.recognizeText(from: scannedPages)
+        resultEditorView.recognizeText(from: scannedPages)
+        statusLabel.stringValue = "Edit recognized text, then save or print"
         previewContainer.layoutSubtreeIfNeeded()
         resultPreviewContainer.layoutSubtreeIfNeeded()
     }
@@ -601,7 +595,7 @@ final class ScannerOverlayView: NSView, AppearanceRefreshable {
         progressIndicator.stopAnimation(nil)
         switch result {
         case .success(let image):
-            displayScannedImage(image)
+            processScannedImage(image)
             onScanComplete?(image)
         case .failure(let error):
             if case ScanError.cancelled = error { return }
@@ -611,13 +605,13 @@ final class ScannerOverlayView: NSView, AppearanceRefreshable {
     }
 
     private func saveAsPDF() {
-        guard let image = scannedImage else { return }
-        ScanExporter.saveAsPDF(pages: [image], from: window)
+        let text = editorView.isHidden ? resultEditorView.text : editorView.text
+        PrintService.saveTextAsPDF(text, defaultName: "Scan", from: window)
     }
 
     private func printScan() {
-        guard let image = scannedImage else { return }
-        PrintService.printPhoto(image, title: "Scan", from: window)
+        let text = editorView.isHidden ? resultEditorView.text : editorView.text
+        PrintService.printText(text, title: "Scan", from: window)
     }
 
     @objc private func paperSizeChanged() {