Parcourir la source

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 il y a 1 mois
Parent
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() {