Browse Source

Enable upload file across text tools with Vision OCR for images.

Wire a shared text extraction service into all writing tools so users can import plain text files or pull text from photos via on-device OCR.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 1 tháng trước cách đây
mục cha
commit
0a6afc7cec

+ 136 - 0
gramora/Services/TextExtractionService.swift

@@ -0,0 +1,136 @@
+import AppKit
+import Foundation
+import UniformTypeIdentifiers
+import Vision
+
+protocol TextExtracting {
+    func pickAndExtractText() async throws -> String?
+}
+
+enum TextExtractionServiceError: LocalizedError {
+    case cancelled
+    case unsupportedFile
+    case readFailed
+    case noTextFound
+
+    var errorDescription: String? {
+        switch self {
+        case .cancelled:
+            return nil
+        case .unsupportedFile:
+            return "The selected file type is not supported."
+        case .readFailed:
+            return "Could not read the selected file."
+        case .noTextFound:
+            return "No text was found in the selected image."
+        }
+    }
+}
+
+struct TextExtractionService: TextExtracting {
+    func pickAndExtractText() async throws -> String? {
+        let panel = NSOpenPanel()
+        panel.title = "Choose a file"
+        panel.message = "Select a text file or an image containing text."
+        panel.allowedContentTypes = [
+            .plainText,
+            .text,
+            .utf8PlainText,
+            .utf16PlainText,
+            .png,
+            .jpeg,
+            .tiff,
+            .heic,
+            .gif,
+            .bmp,
+            .webP
+        ]
+        panel.allowsMultipleSelection = false
+        panel.canChooseDirectories = false
+
+        guard panel.runModal() == .OK, let url = panel.url else {
+            return nil
+        }
+
+        return try await extractText(from: url)
+    }
+
+    private func extractText(from url: URL) async throws -> String {
+        if isImage(url) {
+            return try await extractTextFromImage(at: url)
+        }
+
+        if let text = try? readTextFile(at: url) {
+            return text
+        }
+
+        throw TextExtractionServiceError.unsupportedFile
+    }
+
+    private func isImage(_ url: URL) -> Bool {
+        if let contentType = try? url.resourceValues(forKeys: [.contentTypeKey]).contentType,
+           contentType.conforms(to: .image) {
+            return true
+        }
+
+        let imageExtensions = ["png", "jpg", "jpeg", "tiff", "tif", "heic", "heif", "gif", "bmp", "webp"]
+        return imageExtensions.contains(url.pathExtension.lowercased())
+    }
+
+    private func readTextFile(at url: URL) throws -> String {
+        let data = try Data(contentsOf: url)
+
+        if let utf8 = String(data: data, encoding: .utf8), !utf8.isEmpty {
+            return utf8
+        }
+        if let utf16 = String(data: data, encoding: .utf16), !utf16.isEmpty {
+            return utf16
+        }
+        if let macOSRoman = String(data: data, encoding: .macOSRoman), !macOSRoman.isEmpty {
+            return macOSRoman
+        }
+        if let windows = String(data: data, encoding: .windowsCP1252), !windows.isEmpty {
+            return windows
+        }
+
+        throw TextExtractionServiceError.readFailed
+    }
+
+    private func extractTextFromImage(at url: URL) async throws -> String {
+        guard let image = NSImage(contentsOf: url),
+              let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
+            throw TextExtractionServiceError.readFailed
+        }
+
+        return try await withCheckedThrowingContinuation { continuation in
+            let request = VNRecognizeTextRequest { request, error in
+                if let error {
+                    continuation.resume(throwing: error)
+                    return
+                }
+
+                let observations = request.results as? [VNRecognizedTextObservation] ?? []
+                let extractedText = observations
+                    .compactMap { $0.topCandidates(1).first?.string }
+                    .joined(separator: "\n")
+                    .trimmingCharacters(in: .whitespacesAndNewlines)
+
+                if extractedText.isEmpty {
+                    continuation.resume(throwing: TextExtractionServiceError.noTextFound)
+                } else {
+                    continuation.resume(returning: extractedText)
+                }
+            }
+
+            request.recognitionLevel = .accurate
+            request.usesLanguageCorrection = true
+
+            let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
+            do {
+                try handler.perform([request])
+            } catch {
+                continuation.resume(throwing: error)
+            }
+        }
+    }
+}

+ 16 - 1
gramora/ViewModels/GrammarCheckerViewModel.swift

@@ -15,13 +15,16 @@ final class GrammarCheckerViewModel: ObservableObject {
 
     private let grammarCheckService: any GrammarCheckServicing
     private let historyStorage: HistoryStoring
+    private let textExtractionService: any TextExtracting
 
     init(
         grammarCheckService: any GrammarCheckServicing = GrammarCheckService(),
-        historyStorage: HistoryStoring = HistoryStorageService.shared
+        historyStorage: HistoryStoring = HistoryStorageService.shared,
+        textExtractionService: any TextExtracting = TextExtractionService()
     ) {
         self.grammarCheckService = grammarCheckService
         self.historyStorage = historyStorage
+        self.textExtractionService = textExtractionService
     }
 
     var wordCountLimitLabel: String {
@@ -43,6 +46,18 @@ final class GrammarCheckerViewModel: ObservableObject {
         resetResults()
     }
 
+    func uploadFile() {
+        Task {
+            do {
+                guard let extracted = try await textExtractionService.pickAndExtractText() else { return }
+                text = extracted.truncated(toMaxWords: Self.maxWords)
+                resetResults()
+            } catch {
+                errorMessage = error.localizedDescription
+            }
+        }
+    }
+
     func clearText() {
         text = ""
         resetResults()

+ 13 - 17
gramora/ViewModels/LanguageTranslatorViewModel.swift

@@ -1,7 +1,6 @@
 import AppKit
 import Combine
 import Foundation
-import UniformTypeIdentifiers
 
 @MainActor
 final class LanguageTranslatorViewModel: ObservableObject {
@@ -16,13 +15,16 @@ final class LanguageTranslatorViewModel: ObservableObject {
 
     private let translationService: any TranslationServicing
     private let historyStorage: HistoryStoring
+    private let textExtractionService: any TextExtracting
 
     init(
         translationService: any TranslationServicing = TranslationService(),
-        historyStorage: HistoryStoring = HistoryStorageService.shared
+        historyStorage: HistoryStoring = HistoryStorageService.shared,
+        textExtractionService: any TextExtracting = TextExtractionService()
     ) {
         self.translationService = translationService
         self.historyStorage = historyStorage
+        self.textExtractionService = textExtractionService
     }
 
     var wordCountLimitLabel: String {
@@ -49,21 +51,15 @@ final class LanguageTranslatorViewModel: ObservableObject {
     }
 
     func uploadFile() {
-        let panel = NSOpenPanel()
-        panel.title = "Choose a text file"
-        panel.allowedContentTypes = [.plainText, .text, .utf8PlainText]
-        panel.allowsMultipleSelection = false
-        panel.canChooseDirectories = false
-
-        guard panel.runModal() == .OK, let url = panel.url else { return }
-
-        do {
-            let contents = try String(contentsOf: url, encoding: .utf8)
-            sourceText = contents.truncated(toMaxWords: Self.maxWords)
-            translatedText = ""
-            errorMessage = nil
-        } catch {
-            errorMessage = "Could not read the selected file."
+        Task {
+            do {
+                guard let extracted = try await textExtractionService.pickAndExtractText() else { return }
+                sourceText = extracted.truncated(toMaxWords: Self.maxWords)
+                translatedText = ""
+                errorMessage = nil
+            } catch {
+                errorMessage = error.localizedDescription
+            }
         }
     }
 

+ 19 - 1
gramora/ViewModels/ParaphrasingViewModel.swift

@@ -12,9 +12,14 @@ final class ParaphrasingViewModel: ObservableObject {
     @Published private(set) var errorMessage: String?
 
     private let paraphrasingService: any ParaphrasingServicing
+    private let textExtractionService: any TextExtracting
 
-    init(paraphrasingService: any ParaphrasingServicing = ParaphrasingService()) {
+    init(
+        paraphrasingService: any ParaphrasingServicing = ParaphrasingService(),
+        textExtractionService: any TextExtracting = TextExtractionService()
+    ) {
         self.paraphrasingService = paraphrasingService
+        self.textExtractionService = textExtractionService
     }
 
     var wordCountLimitLabel: String {
@@ -32,6 +37,19 @@ final class ParaphrasingViewModel: ObservableObject {
         errorMessage = nil
     }
 
+    func uploadFile() {
+        Task {
+            do {
+                guard let extracted = try await textExtractionService.pickAndExtractText() else { return }
+                text = extracted.truncated(toMaxWords: Self.maxWords)
+                paraphrasedText = ""
+                errorMessage = nil
+            } catch {
+                errorMessage = error.localizedDescription
+            }
+        }
+    }
+
     func clearText() {
         text = ""
         paraphrasedText = ""

+ 18 - 1
gramora/ViewModels/PunctuationCheckerViewModel.swift

@@ -14,9 +14,14 @@ final class PunctuationCheckerViewModel: ObservableObject {
     @Published private(set) var errorMessage: String?
 
     private let punctuationCheckService: any PunctuationCheckServicing
+    private let textExtractionService: any TextExtracting
 
-    init(punctuationCheckService: any PunctuationCheckServicing = PunctuationCheckService()) {
+    init(
+        punctuationCheckService: any PunctuationCheckServicing = PunctuationCheckService(),
+        textExtractionService: any TextExtracting = TextExtractionService()
+    ) {
         self.punctuationCheckService = punctuationCheckService
+        self.textExtractionService = textExtractionService
     }
 
     var wordCountLimitLabel: String {
@@ -33,6 +38,18 @@ final class PunctuationCheckerViewModel: ObservableObject {
         resetResults()
     }
 
+    func uploadFile() {
+        Task {
+            do {
+                guard let extracted = try await textExtractionService.pickAndExtractText() else { return }
+                text = extracted.truncated(toMaxWords: Self.maxWords)
+                resetResults()
+            } catch {
+                errorMessage = error.localizedDescription
+            }
+        }
+    }
+
     func clearText() {
         text = ""
         resetResults()

+ 16 - 1
gramora/ViewModels/SpellCheckerViewModel.swift

@@ -15,13 +15,16 @@ final class SpellCheckerViewModel: ObservableObject {
 
     private let spellCheckService: any SpellCheckServicing
     private let historyStorage: HistoryStoring
+    private let textExtractionService: any TextExtracting
 
     init(
         spellCheckService: any SpellCheckServicing = SpellCheckService(),
-        historyStorage: HistoryStoring = HistoryStorageService.shared
+        historyStorage: HistoryStoring = HistoryStorageService.shared,
+        textExtractionService: any TextExtracting = TextExtractionService()
     ) {
         self.spellCheckService = spellCheckService
         self.historyStorage = historyStorage
+        self.textExtractionService = textExtractionService
     }
 
     var wordCountLimitLabel: String {
@@ -43,6 +46,18 @@ final class SpellCheckerViewModel: ObservableObject {
         resetResults()
     }
 
+    func uploadFile() {
+        Task {
+            do {
+                guard let extracted = try await textExtractionService.pickAndExtractText() else { return }
+                text = extracted.truncated(toMaxWords: Self.maxWords)
+                resetResults()
+            } catch {
+                errorMessage = error.localizedDescription
+            }
+        }
+    }
+
     func clearText() {
         text = ""
         resetResults()

+ 19 - 1
gramora/ViewModels/SummarizerViewModel.swift

@@ -12,9 +12,14 @@ final class SummarizerViewModel: ObservableObject {
     @Published private(set) var errorMessage: String?
 
     private let summarizerService: any SummarizerServicing
+    private let textExtractionService: any TextExtracting
 
-    init(summarizerService: any SummarizerServicing = SummarizerService()) {
+    init(
+        summarizerService: any SummarizerServicing = SummarizerService(),
+        textExtractionService: any TextExtracting = TextExtractionService()
+    ) {
         self.summarizerService = summarizerService
+        self.textExtractionService = textExtractionService
     }
 
     var wordCountLimitLabel: String {
@@ -32,6 +37,19 @@ final class SummarizerViewModel: ObservableObject {
         errorMessage = nil
     }
 
+    func uploadFile() {
+        Task {
+            do {
+                guard let extracted = try await textExtractionService.pickAndExtractText() else { return }
+                text = extracted.truncated(toMaxWords: Self.maxWords)
+                summary = ""
+                errorMessage = nil
+            } catch {
+                errorMessage = error.localizedDescription
+            }
+        }
+    }
+
     func clearText() {
         text = ""
         summary = ""

+ 20 - 0
gramora/ViewModels/WordsCountViewModel.swift

@@ -7,6 +7,13 @@ final class WordsCountViewModel: ObservableObject {
     static let maxWords = 50_000
 
     @Published var text: String = ""
+    @Published private(set) var errorMessage: String?
+
+    private let textExtractionService: any TextExtracting
+
+    init(textExtractionService: any TextExtracting = TextExtractionService()) {
+        self.textExtractionService = textExtractionService
+    }
 
     var characterCount: Int { text.count }
 
@@ -44,7 +51,20 @@ final class WordsCountViewModel: ObservableObject {
         text = clipboard.truncated(toMaxWords: Self.maxWords)
     }
 
+    func uploadFile() {
+        Task {
+            do {
+                guard let extracted = try await textExtractionService.pickAndExtractText() else { return }
+                text = extracted.truncated(toMaxWords: Self.maxWords)
+                errorMessage = nil
+            } catch {
+                errorMessage = error.localizedDescription
+            }
+        }
+    }
+
     func clearText() {
         text = ""
+        errorMessage = nil
     }
 }

+ 3 - 1
gramora/Views/GrammarCheckerView.swift

@@ -151,7 +151,9 @@ struct GrammarCheckerView: View {
                     viewModel.pasteText()
                 }
 
-                ToolbarButton(title: "Upload File", iconName: "square.and.arrow.up") {}
+                ToolbarButton(title: "Upload File", iconName: "square.and.arrow.up") {
+                    viewModel.uploadFile()
+                }
 
                 Spacer()
 

+ 3 - 1
gramora/Views/ParaphrasingView.swift

@@ -139,7 +139,9 @@ struct ParaphrasingView: View {
                     viewModel.pasteText()
                 }
 
-                ToolbarButton(title: "Upload File", iconName: "square.and.arrow.up") {}
+                ToolbarButton(title: "Upload File", iconName: "square.and.arrow.up") {
+                    viewModel.uploadFile()
+                }
 
                 Spacer()
 

+ 3 - 1
gramora/Views/PunctuationCheckerView.swift

@@ -139,7 +139,9 @@ struct PunctuationCheckerView: View {
                     viewModel.pasteText()
                 }
 
-                ToolbarButton(title: "Upload File", iconName: "square.and.arrow.up") {}
+                ToolbarButton(title: "Upload File", iconName: "square.and.arrow.up") {
+                    viewModel.uploadFile()
+                }
 
                 Spacer()
 

+ 3 - 1
gramora/Views/SpellCheckerView.swift

@@ -151,7 +151,9 @@ struct SpellCheckerView: View {
                     viewModel.pasteText()
                 }
 
-                ToolbarButton(title: "Upload File", iconName: "square.and.arrow.up") {}
+                ToolbarButton(title: "Upload File", iconName: "square.and.arrow.up") {
+                    viewModel.uploadFile()
+                }
 
                 Spacer()
 

+ 3 - 1
gramora/Views/SummarizerView.swift

@@ -139,7 +139,9 @@ struct SummarizerView: View {
                     viewModel.pasteText()
                 }
 
-                ToolbarButton(title: "Upload File", iconName: "square.and.arrow.up") {}
+                ToolbarButton(title: "Upload File", iconName: "square.and.arrow.up") {
+                    viewModel.uploadFile()
+                }
 
                 Spacer()
 

+ 10 - 1
gramora/Views/WordsCountView.swift

@@ -14,6 +14,13 @@ struct WordsCountView: View {
 
                 contentCard
                     .frame(maxWidth: .infinity, maxHeight: .infinity)
+
+                if let errorMessage = viewModel.errorMessage {
+                    Text(errorMessage)
+                        .font(.system(size: 14))
+                        .foregroundStyle(.red)
+                        .padding(.top, 12)
+                }
             }
             .frame(maxWidth: 1200, maxHeight: .infinity, alignment: .topLeading)
             .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
@@ -99,7 +106,9 @@ struct WordsCountView: View {
                     viewModel.pasteText()
                 }
 
-                ToolbarButton(title: "Upload File", iconName: "square.and.arrow.up") {}
+                ToolbarButton(title: "Upload File", iconName: "square.and.arrow.up") {
+                    viewModel.uploadFile()
+                }
 
                 Spacer()