|
|
@@ -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)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|