diff --git a/ScreenTranslate.xcodeproj/project.pbxproj b/ScreenTranslate.xcodeproj/project.pbxproj index 2b85206..7a780cc 100644 --- a/ScreenTranslate.xcodeproj/project.pbxproj +++ b/ScreenTranslate.xcodeproj/project.pbxproj @@ -10,8 +10,19 @@ 86DCC59B2F56BB9D000ECF4B /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 86DCC59A2F56BB9D000ECF4B /* Sparkle */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + SC000032 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = SC000010 /* Project object */; + proxyType = 1; + remoteGlobalIDString = SC000006; + remoteInfo = ScreenTranslate; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ SC000001 /* ScreenTranslate.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ScreenTranslate.app; sourceTree = BUILT_PRODUCTS_DIR; }; + SC000022 /* ScreenTranslateTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ScreenTranslateTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -33,6 +44,11 @@ path = ScreenTranslate; sourceTree = ""; }; + SC000021 /* ScreenTranslateTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = ScreenTranslateTests; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -51,6 +67,7 @@ isa = PBXGroup; children = ( SC000002 /* ScreenTranslate */, + SC000021 /* ScreenTranslateTests */, SC000005 /* Products */, ); sourceTree = ""; @@ -59,6 +76,7 @@ isa = PBXGroup; children = ( SC000001 /* ScreenTranslate.app */, + SC000022 /* ScreenTranslateTests.xctest */, ); name = Products; sourceTree = ""; @@ -90,6 +108,29 @@ productReference = SC000001 /* ScreenTranslate.app */; productType = "com.apple.product-type.application"; }; + SC000023 /* ScreenTranslateTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = SC000026 /* Build configuration list for PBXNativeTarget "ScreenTranslateTests" */; + buildPhases = ( + SC000024 /* Sources */, + SC000025 /* Frameworks */, + SC000027 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + SC000033 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + SC000021 /* ScreenTranslateTests */, + ); + name = ScreenTranslateTests; + packageProductDependencies = ( + ); + productName = ScreenTranslateTests; + productReference = SC000022 /* ScreenTranslateTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -103,6 +144,10 @@ SC000006 = { CreatedOnToolsVersion = 26.2; }; + SC000023 = { + CreatedOnToolsVersion = 26.2; + TestTargetID = SC000006; + }; }; }; buildConfigurationList = SC000011 /* Build configuration list for PBXProject "ScreenTranslate" */; @@ -132,6 +177,7 @@ projectRoot = ""; targets = ( SC000006 /* ScreenTranslate */, + SC000023 /* ScreenTranslateTests */, ); }; /* End PBXProject section */ @@ -144,6 +190,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + SC000027 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -170,8 +223,33 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + SC000024 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXFrameworksBuildPhase section */ + SC000025 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXTargetDependency section */ + SC000033 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = SC000006 /* ScreenTranslate */; + targetProxy = SC000032 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ SC000012 /* Debug */ = { isa = XCBuildConfiguration; @@ -367,6 +445,48 @@ }; name = Release; }; + SC000028 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_IDENTITY = "-"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 3; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 26.0; + PRODUCT_BUNDLE_IDENTIFIER = com.screentranslate.appTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/ScreenTranslate.app/Contents/MacOS/ScreenTranslate"; + TEST_TARGET_NAME = ScreenTranslate; + }; + name = Debug; + }; + SC000029 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_IDENTITY = "-"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 3; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 26.0; + PRODUCT_BUNDLE_IDENTIFIER = com.screentranslate.appTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/ScreenTranslate.app/Contents/MacOS/ScreenTranslate"; + TEST_TARGET_NAME = ScreenTranslate; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -388,6 +508,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + SC000026 /* Build configuration list for PBXNativeTarget "ScreenTranslateTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + SC000028 /* Debug */, + SC000029 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ diff --git a/ScreenTranslate/Features/Onboarding/OnboardingViewModel.swift b/ScreenTranslate/Features/Onboarding/OnboardingViewModel.swift index bd047a4..6c04914 100644 --- a/ScreenTranslate/Features/Onboarding/OnboardingViewModel.swift +++ b/ScreenTranslate/Features/Onboarding/OnboardingViewModel.swift @@ -288,6 +288,7 @@ final class OnboardingViewModel { translationTestSuccess = true } else { let config = TranslationEngine.Configuration( + sourceLanguage: nil, targetLanguage: TranslationLanguage.chineseSimplified, timeout: 10.0, autoDetectSourceLanguage: true diff --git a/ScreenTranslate/Features/TextTranslation/TextTranslationPopupWindow.swift b/ScreenTranslate/Features/TextTranslation/TextTranslationPopupWindow.swift index b57f315..425596c 100644 --- a/ScreenTranslate/Features/TextTranslation/TextTranslationPopupWindow.swift +++ b/ScreenTranslate/Features/TextTranslation/TextTranslationPopupWindow.swift @@ -108,13 +108,11 @@ final class TextTranslationPopupController: NSObject { } private func languageDisplayName(for code: String?) -> String { - guard let code = code, !code.isEmpty else { - return NSLocalizedString("language.auto", value: "Auto Detected", comment: "") - } - if let languageName = Locale.current.localizedString(forLanguageCode: code) { - return languageName - } - return code.uppercased() + TranslationLanguage.displayName( + for: code, + locale: .current, + autoDisplayName: NSLocalizedString("language.auto", value: "Auto Detected", comment: "") + ) } private func calculateWindowSize(originalText: String, translatedText: String) -> NSSize { diff --git a/ScreenTranslate/Features/TranslationFlow/TranslationFlowController.swift b/ScreenTranslate/Features/TranslationFlow/TranslationFlowController.swift index ed20ee1..348e77f 100644 --- a/ScreenTranslate/Features/TranslationFlow/TranslationFlowController.swift +++ b/ScreenTranslate/Features/TranslationFlow/TranslationFlowController.swift @@ -193,10 +193,15 @@ final class TranslationFlowController { let settings = AppSettings.shared let targetLanguage = settings.translationTargetLanguage?.rawValue ?? "zh-Hans" - let sourceLanguage = settings.translationSourceLanguage.rawValue + let sourceLanguage: String? = settings.translationSourceLanguage == .auto + ? nil + : settings.translationSourceLanguage.rawValue let engine = settings.translationEngine - - let texts = analysisResult.segments.map { $0.text } + let filteredAnalysisResult = analysisResult.filteredForTranslation() + if filteredAnalysisResult.segments.isEmpty { + throw TranslationFlowError.noTextFound + } + let texts = filteredAnalysisResult.segments.map(\.text) if #available(macOS 13.0, *) { let translatedSegments = try await TranslationService.shared.translate( @@ -207,7 +212,7 @@ final class TranslationFlowController { ) // Merge bounding box info from VLM analysis back into translated segments - bilingualSegments = zip(analysisResult.segments, translatedSegments).map { original, translated in + bilingualSegments = zip(filteredAnalysisResult.segments, translatedSegments).map { original, translated in BilingualSegment( segment: original, translatedText: translated.translated, diff --git a/ScreenTranslate/Models/AppLanguage.swift b/ScreenTranslate/Models/AppLanguage.swift index 8e6f5d4..d73dd3a 100644 --- a/ScreenTranslate/Models/AppLanguage.swift +++ b/ScreenTranslate/Models/AppLanguage.swift @@ -9,6 +9,22 @@ enum AppLanguage: String, CaseIterable, Identifiable, Sendable { case english = "en" /// Simplified Chinese case simplifiedChinese = "zh-Hans" + /// German + case german = "de" + /// Spanish + case spanish = "es" + /// French + case french = "fr" + /// Italian + case italian = "it" + /// Japanese + case japanese = "ja" + /// Korean + case korean = "ko" + /// Portuguese + case portuguese = "pt" + /// Russian + case russian = "ru" var id: String { rawValue } @@ -21,6 +37,22 @@ enum AppLanguage: String, CaseIterable, Identifiable, Sendable { return "English" case .simplifiedChinese: return "简体中文" + case .german: + return "Deutsch" + case .spanish: + return "Español" + case .french: + return "Français" + case .italian: + return "Italiano" + case .japanese: + return "日本語" + case .korean: + return "한국어" + case .portuguese: + return "Português" + case .russian: + return "Русский" } } @@ -29,10 +61,8 @@ enum AppLanguage: String, CaseIterable, Identifiable, Sendable { switch self { case .system: return nil - case .english: - return "en" - case .simplifiedChinese: - return "zh-Hans" + default: + return rawValue } } @@ -40,6 +70,26 @@ enum AppLanguage: String, CaseIterable, Identifiable, Sendable { static var supportedLanguageCodes: [String] { allCases.compactMap { $0.localeIdentifier } } + + static func from(localeIdentifier: String) -> AppLanguage? { + let normalizedIdentifier = localeIdentifier.replacingOccurrences(of: "_", with: "-").lowercased() + + if normalizedIdentifier.hasPrefix("zh-hans") + || normalizedIdentifier == "zh-cn" + || normalizedIdentifier == "zh-sg" { + return .simplifiedChinese + } + + return allCases.first { language in + guard language != .system else { + return false + } + + let languageCode = language.rawValue.lowercased() + return normalizedIdentifier == languageCode + || normalizedIdentifier.hasPrefix(languageCode + "-") + } + } } /// Manages application language settings and provides runtime language switching. @@ -100,19 +150,15 @@ final class LanguageManager { } // For system, detect the preferred language - let preferredLanguages = Locale.preferredLanguages - for preferred in preferredLanguages { - // Check if we support this language - if preferred.hasPrefix("zh-Hans") || preferred.hasPrefix("zh_Hans") || preferred == "zh-CN" { - return "zh-Hans" - } - if preferred.hasPrefix("en") { - return "en" + for preferredLanguage in Locale.preferredLanguages { + if let matchedLanguage = AppLanguage.from(localeIdentifier: preferredLanguage), + let localeIdentifier = matchedLanguage.localeIdentifier { + return localeIdentifier } } - + // Default to English - return "en" + return AppLanguage.english.rawValue } // MARK: - Private Methods diff --git a/ScreenTranslate/Models/ScreenAnalysisResult.swift b/ScreenTranslate/Models/ScreenAnalysisResult.swift index bf2233c..c7e9d66 100644 --- a/ScreenTranslate/Models/ScreenAnalysisResult.swift +++ b/ScreenTranslate/Models/ScreenAnalysisResult.swift @@ -57,6 +57,53 @@ extension TextSegment { y: boundingBox.midY * imageSize.height ) } + + /// Heuristic filter for OCR noise that should not be translated as primary content. + var isLikelyTranslationNoise: Bool { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return true } + let isNearImageEdge = + boundingBox.minX < 0.08 + || boundingBox.maxX > 0.92 + || boundingBox.minY < 0.08 + || boundingBox.maxY > 0.92 + + // Filter coordinate-like strings (e.g., "0.5, 0.3", "(x:0.5, y:0.3)", "x: 0.5") + if trimmed.range(of: #"^[\(\[]?[xy]?\s*[:\:]?\s*[\d.]+\s*[,,]\s*[xy]?\s*[:\:]?\s*[\d.]+[\)\]]?$"#, options: .regularExpression) != nil { + return true + } + // Filter single coordinate values (e.g., "x: 0.5", "y: 0.3") + if trimmed.range(of: #"^[xy]\s*[:\:]?\s*[\d.]+$"#, options: .regularExpression) != nil { + return true + } + + if trimmed.count == 1, + trimmed.range(of: #"^[\d\p{P}\p{S}]$"#, options: .regularExpression) != nil { + return true + } + + if trimmed.count <= 12, + trimmed.range(of: #"^[\d\s.,:;%+\-_=(){}\[\]/\\|<>]+$"#, options: .regularExpression) != nil { + return true + } + + if trimmed.count <= 4, + confidence < 0.35, + trimmed.range(of: #"^[\p{P}\p{S}\dA-Za-z]{1,4}$"#, options: .regularExpression) != nil { + return true + } + + if isNearImageEdge, + trimmed.count <= 8, + trimmed.range( + of: #"^(?:q[1-4]|jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec|mon|tue|wed|thu|fri|sat|sun|\d{1,2}:\d{2}(?:am|pm)?|\d{4})$"#, + options: [.regularExpression, .caseInsensitive] + ) != nil { + return true + } + + return false + } } // MARK: - ScreenAnalysisResult @@ -104,6 +151,13 @@ struct ScreenAnalysisResult: Codable, Sendable, Equatable { func segments(in rect: CGRect) -> [TextSegment] { segments.filter { $0.boundingBox.intersects(rect) } } + + /// Removes coordinate ticks, isolated symbols, and similar OCR noise before translation. + func filteredForTranslation() -> ScreenAnalysisResult { + let filteredSegments = segments.filter { !$0.isLikelyTranslationNoise } + let segmentsToUse = filteredSegments.isEmpty ? segments : filteredSegments + return ScreenAnalysisResult(segments: segmentsToUse, imageSize: imageSize) + } } // MARK: - Empty Result diff --git a/ScreenTranslate/Resources/en.lproj/Localizable.strings b/ScreenTranslate/Resources/en.lproj/Localizable.strings index b295db7..bb0febb 100644 --- a/ScreenTranslate/Resources/en.lproj/Localizable.strings +++ b/ScreenTranslate/Resources/en.lproj/Localizable.strings @@ -736,7 +736,7 @@ "textTranslation.phase.failed" = "Failed"; "textTranslation.error.emptyInput" = "No text to translate"; -"textTranslation.error.translationFailed %@" = "Translation failed: %@"; +"textTranslation.error.translationFailed" = "Translation failed: %@"; "textTranslation.error.cancelled" = "Translation was cancelled"; "textTranslation.error.serviceUnavailable" = "Translation service is unavailable"; @@ -813,4 +813,3 @@ "about.acknowledgements.upstream" = "Based on"; "about.acknowledgements.author.format" = "by %@"; "about.close" = "Close"; - diff --git a/ScreenTranslate/Resources/fr.lproj/Localizable.strings b/ScreenTranslate/Resources/fr.lproj/Localizable.strings index dc0c027..77641fe 100644 --- a/ScreenTranslate/Resources/fr.lproj/Localizable.strings +++ b/ScreenTranslate/Resources/fr.lproj/Localizable.strings @@ -736,7 +736,7 @@ "textTranslation.phase.failed" = "Échec"; "textTranslation.error.emptyInput" = "Aucun texte à traduire"; -"textTranslation.error.translationFailed %@" = "Échec de la traduction : %@"; +"textTranslation.error.translationFailed" = "Échec de la traduction : %@"; "textTranslation.error.cancelled" = "La traduction a été annulée"; "textTranslation.error.serviceUnavailable" = "Le service de traduction n'est pas disponible"; diff --git a/ScreenTranslate/Resources/ko.lproj/Localizable.strings b/ScreenTranslate/Resources/ko.lproj/Localizable.strings index c120c40..7000aab 100644 --- a/ScreenTranslate/Resources/ko.lproj/Localizable.strings +++ b/ScreenTranslate/Resources/ko.lproj/Localizable.strings @@ -736,7 +736,7 @@ "textTranslation.phase.failed" = "실패함"; "textTranslation.error.emptyInput" = "번역할 텍스트가 없습니다"; -"textTranslation.error.translationFailed %@" = "번역 실패: %@"; +"textTranslation.error.translationFailed" = "번역 실패: %@"; "textTranslation.error.cancelled" = "번역이 취소되었습니다"; "textTranslation.error.serviceUnavailable" = "번역 서비스를 사용할 수 없습니다"; diff --git a/ScreenTranslate/Resources/pt.lproj/Localizable.strings b/ScreenTranslate/Resources/pt.lproj/Localizable.strings index f981688..42ba271 100644 --- a/ScreenTranslate/Resources/pt.lproj/Localizable.strings +++ b/ScreenTranslate/Resources/pt.lproj/Localizable.strings @@ -751,7 +751,7 @@ Conceda permissão em Configurações do Sistema > Privacidade e Segurança > Mo "textTranslation.phase.failed" = "Falhou"; "textTranslation.error.emptyInput" = "Nenhum texto para traduzir"; -"textTranslation.error.translationFailed %@" = "Falha na tradução: %@"; +"textTranslation.error.translationFailed" = "Falha na tradução: %@"; "textTranslation.error.cancelled" = "Tradução foi cancelada"; "textTranslation.error.serviceUnavailable" = "Serviço de tradução indisponível"; @@ -828,4 +828,3 @@ Conceda permissão em Configurações do Sistema > Privacidade e Segurança > Mo "about.acknowledgements.upstream" = "Baseado em"; "about.acknowledgements.author.format" = "por %@"; "about.close" = "Fechar"; - diff --git a/ScreenTranslate/Resources/ru.lproj/Localizable.strings b/ScreenTranslate/Resources/ru.lproj/Localizable.strings index beb2747..c68114e 100644 --- a/ScreenTranslate/Resources/ru.lproj/Localizable.strings +++ b/ScreenTranslate/Resources/ru.lproj/Localizable.strings @@ -736,7 +736,7 @@ "textTranslation.phase.failed" = "Ошибка"; "textTranslation.error.emptyInput" = "Нет текста для перевода"; -"textTranslation.error.translationFailed %@" = "Ошибка перевода: %@"; +"textTranslation.error.translationFailed" = "Ошибка перевода: %@"; "textTranslation.error.cancelled" = "Перевод отменён"; "textTranslation.error.serviceUnavailable" = "Сервис перевода недоступен"; diff --git a/ScreenTranslate/Resources/zh-Hans.lproj/Localizable.strings b/ScreenTranslate/Resources/zh-Hans.lproj/Localizable.strings index 50a7433..e9b4f16 100644 --- a/ScreenTranslate/Resources/zh-Hans.lproj/Localizable.strings +++ b/ScreenTranslate/Resources/zh-Hans.lproj/Localizable.strings @@ -736,7 +736,7 @@ "textTranslation.phase.failed" = "失败"; "textTranslation.error.emptyInput" = "没有可翻译的文本"; -"textTranslation.error.translationFailed %@" = "翻译失败:%@"; +"textTranslation.error.translationFailed" = "翻译失败:%@"; "textTranslation.error.cancelled" = "翻译已取消"; "textTranslation.error.serviceUnavailable" = "翻译服务不可用"; diff --git a/ScreenTranslate/Services/AppleTranslationProvider.swift b/ScreenTranslate/Services/AppleTranslationProvider.swift index 60dd9c9..4ee5603 100644 --- a/ScreenTranslate/Services/AppleTranslationProvider.swift +++ b/ScreenTranslate/Services/AppleTranslationProvider.swift @@ -31,9 +31,13 @@ actor AppleTranslationProvider: TranslationProvider { guard let target = TranslationLanguage(rawValue: targetLanguage) else { throw TranslationProviderError.unsupportedLanguage(targetLanguage) } - + + var config = TranslationEngine.Configuration.default + config.targetLanguage = target + config.sourceLanguage = TranslationLanguage.fromTranslationCode(sourceLanguage) + do { - return try await engine.translate(text, to: target) + return try await engine.translate(text, config: config) } catch let error as TranslationEngineError { throw mapEngineError(error) } diff --git a/ScreenTranslate/Services/Translation/Providers/CompatibleTranslationProvider.swift b/ScreenTranslate/Services/Translation/Providers/CompatibleTranslationProvider.swift index 3a28b6e..65fb428 100644 --- a/ScreenTranslate/Services/Translation/Providers/CompatibleTranslationProvider.swift +++ b/ScreenTranslate/Services/Translation/Providers/CompatibleTranslationProvider.swift @@ -218,9 +218,10 @@ actor CompatibleTranslationProvider: TranslationProvider { sourceLanguage: String?, targetLanguage: String ) -> String { - let source = sourceLanguage ?? "auto-detect" + let source = TranslationLanguage.promptDisplayName(for: sourceLanguage) + let target = TranslationLanguage.promptDisplayName(for: targetLanguage) return """ - Translate the following text from \(source) to \(targetLanguage). + Translate the following text from \(source) to \(target). Provide ONLY the translated text without any explanations or additional text. Text to translate: diff --git a/ScreenTranslate/Services/Translation/Providers/LLMTranslationProvider.swift b/ScreenTranslate/Services/Translation/Providers/LLMTranslationProvider.swift index 39d2269..1f7b828 100644 --- a/ScreenTranslate/Services/Translation/Providers/LLMTranslationProvider.swift +++ b/ScreenTranslate/Services/Translation/Providers/LLMTranslationProvider.swift @@ -180,19 +180,20 @@ actor LLMTranslationProvider: TranslationProvider { sourceLanguage: String?, targetLanguage: String ) -> String { - let source = sourceLanguage ?? "auto-detect" + let source = TranslationLanguage.promptDisplayName(for: sourceLanguage) + let target = TranslationLanguage.promptDisplayName(for: targetLanguage) // Use custom template if available if let template = customPromptTemplate { return template .replacingOccurrences(of: "{source_language}", with: source) - .replacingOccurrences(of: "{target_language}", with: targetLanguage) + .replacingOccurrences(of: "{target_language}", with: target) .replacingOccurrences(of: "{text}", with: text) } // Default prompt return """ - Translate the following text from \(source) to \(targetLanguage). + Translate the following text from \(source) to \(target). Provide ONLY the translated text without any explanations, notes, or formatting. Text to translate: diff --git a/ScreenTranslate/Services/TranslationEngine.swift b/ScreenTranslate/Services/TranslationEngine.swift index 36afe62..1fc81b3 100644 --- a/ScreenTranslate/Services/TranslationEngine.swift +++ b/ScreenTranslate/Services/TranslationEngine.swift @@ -50,17 +50,75 @@ enum TranslationLanguage: String, CaseIterable, Sendable, Codable { /// Localized display name var localizedName: String { - if self == .auto { - return NSLocalizedString("translation.auto", comment: "") - } - let languageCode = rawValue.components(separatedBy: "-").first ?? rawValue - return Locale.current.localizedString(forLanguageCode: languageCode) ?? rawValue + Self.displayName( + for: rawValue, + locale: .current, + autoDisplayName: NSLocalizedString("translation.auto", comment: "") + ) } /// BCP 47 language tag var bcp47Tag: String { rawValue } + + static func fromTranslationCode(_ code: String?) -> TranslationLanguage? { + guard let code, + !code.isEmpty, + code.lowercased() != TranslationLanguage.auto.rawValue else { + return nil + } + return TranslationLanguage(rawValue: code) + } + + static func promptDisplayName(for code: String?) -> String { + displayName( + for: code, + locale: Locale(identifier: "en"), + autoDisplayName: "Auto Detect" + ) + } + + static func displayName( + for code: String?, + locale: Locale, + autoDisplayName: String + ) -> String { + guard let code, + !code.isEmpty, + code.lowercased() != TranslationLanguage.auto.rawValue else { + return autoDisplayName + } + + let normalized = code.replacingOccurrences(of: "_", with: "-") + + if let fullName = locale.localizedString(forIdentifier: normalized), !fullName.isEmpty { + return normalizedDisplayName(fullName) + } + + let baseLanguageCode = normalized.components(separatedBy: "-").first ?? normalized + if let languageName = locale.localizedString(forLanguageCode: baseLanguageCode), + !languageName.isEmpty { + return normalizedDisplayName(languageName) + } + + return normalized + } + + private static func normalizedDisplayName(_ name: String) -> String { + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + let commaSeparatedParts = trimmed + .split(separator: ",", maxSplits: 1, omittingEmptySubsequences: true) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + + guard commaSeparatedParts.count == 2, + !commaSeparatedParts[0].isEmpty, + !commaSeparatedParts[1].isEmpty else { + return trimmed + } + + return "\(commaSeparatedParts[0]) (\(commaSeparatedParts[1]))" + } } /// Actor responsible for translating text using the Translation framework (macOS 12+). @@ -95,6 +153,9 @@ actor TranslationEngine { /// Translation configuration options struct Configuration: Sendable { + /// Explicit source language for translation. nil means fallback behavior. + var sourceLanguage: TranslationLanguage? + /// Target language for translation (nil for system default) var targetLanguage: TranslationLanguage? @@ -105,6 +166,7 @@ actor TranslationEngine { var autoDetectSourceLanguage: Bool static let `default` = Configuration( + sourceLanguage: nil, targetLanguage: nil, timeout: 10.0, autoDetectSourceLanguage: true @@ -148,7 +210,11 @@ actor TranslationEngine { } // Check language availability before attempting translation - try await validateLanguageAvailability(for: effectiveTargetLanguage) + try await validateLanguageAvailability( + for: effectiveTargetLanguage, + sourceLanguage: config.sourceLanguage, + text: text + ) // Perform translation with signpost for profiling os_signpost(.begin, log: Self.performanceLog, name: "Translation", signpostID: Self.signpostID) @@ -157,6 +223,7 @@ actor TranslationEngine { do { let response = try await performTranslation( text: text, + source: config.sourceLanguage, target: effectiveTargetLanguage, timeout: config.timeout ) @@ -207,9 +274,30 @@ actor TranslationEngine { /// Validates if the target language is available and installed private func validateLanguageAvailability(for language: TranslationLanguage) async throws { - let languageStatus = await Self.checkLanguageAvailability( - target: language.localeLanguage - ) + try await validateLanguageAvailability(for: language, sourceLanguage: nil, text: nil) + } + + /// Validates if the target language is available and installed for the selected source language. + private func validateLanguageAvailability( + for language: TranslationLanguage, + sourceLanguage: TranslationLanguage?, + text: String? + ) async throws { + let languageStatus: LanguageAvailabilityStatus + + if let sourceLocaleLanguage = Self.sourceLocaleLanguage(for: sourceLanguage) { + languageStatus = await Self.checkLanguageAvailability( + source: sourceLocaleLanguage, + target: language.localeLanguage + ) + } else if let text, !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + languageStatus = await Self.checkLanguageAvailability( + text: text, + target: language.localeLanguage + ) + } else { + return + } switch languageStatus { case .installed: @@ -233,16 +321,19 @@ actor TranslationEngine { /// Performs the actual translation with a timeout private func performTranslation( text: String, + source: TranslationLanguage?, target: TranslationLanguage, timeout: TimeInterval ) async throws -> TranslationSession.Response { try await withThrowingTaskGroup( of: Result.self ) { group in - group.addTask { [text, target] in + group.addTask { [text, source, target] in do { + // The current TranslationSession initializer exposed by this SDK + // still requires an installed source language. let session = TranslationSession( - installedSource: Locale.Language(identifier: "en"), + installedSource: (source ?? .english).localeLanguage, target: target.localeLanguage ) let result = try await session.translate(text) @@ -336,11 +427,40 @@ actor TranslationEngine { /// Checks if the target language is available for translation /// - Parameter target: The target language to check /// - Returns: The availability status of the language - private static func checkLanguageAvailability(target: Locale.Language) async -> LanguageAvailabilityStatus { + static func sourceLocaleLanguage(for sourceLanguage: TranslationLanguage?) -> Locale.Language? { + guard let sourceLanguage, sourceLanguage != .auto else { + return nil + } + return sourceLanguage.localeLanguage + } + + private static func checkLanguageAvailability( + source: Locale.Language, + target: Locale.Language + ) async -> LanguageAvailabilityStatus { let availability = LanguageAvailability() - let sourceLanguage = Locale.Language(identifier: "en") - let status = await availability.status(from: sourceLanguage, to: target) + let status = await availability.status(from: source, to: target) + return languageAvailabilityStatus(from: status, target: target) + } + + private static func checkLanguageAvailability( + text: String, + target: Locale.Language + ) async -> LanguageAvailabilityStatus { + let availability = LanguageAvailability() + let status: LanguageAvailability.Status + do { + status = try await availability.status(for: text, to: target) + } catch { + return .unsupported(languageName: target.minimalIdentifier) + } + return languageAvailabilityStatus(from: status, target: target) + } + private static func languageAvailabilityStatus( + from status: LanguageAvailability.Status, + target: Locale.Language + ) -> LanguageAvailabilityStatus { let languageName = target.minimalIdentifier switch status { diff --git a/ScreenTranslate/Services/TranslationService.swift b/ScreenTranslate/Services/TranslationService.swift index 252c6a7..d7b74cb 100644 --- a/ScreenTranslate/Services/TranslationService.swift +++ b/ScreenTranslate/Services/TranslationService.swift @@ -179,13 +179,7 @@ actor TranslationService { group.addTask { do { let start = Date() - guard let provider = await self.registry.provider(for: engine) else { - return EngineResult.failed( - engine: engine, - error: RegistryError.notRegistered(engine), - latency: 0 - ) - } + let provider = try await self.resolvedProvider(for: engine) await self.applyPromptConfig( to: provider, @@ -281,10 +275,7 @@ actor TranslationService { mode: EngineSelectionMode = .primaryWithFallback ) async throws -> TranslationResultBundle { let start = Date() - - guard let provider = await registry.provider(for: engine) else { - throw RegistryError.notRegistered(engine) - } + let provider = try await resolvedProvider(for: engine) guard await provider.isAvailable else { throw TranslationProviderError.notAvailable @@ -338,7 +329,6 @@ actor TranslationService { guard let llmProvider = provider as? LLMTranslationProvider else { return } let sceneToUse = scene ?? .screenshot - let sourceLang = sourceLanguage ?? "auto" let customPrompt = promptConfig.promptPreview( for: engine, @@ -353,6 +343,18 @@ actor TranslationService { } } + private func resolvedProvider(for engine: TranslationEngineType) async throws -> any TranslationProvider { + if let provider = await registry.provider(for: engine) { + return provider + } + + let engineConfig = await MainActor.run { + AppSettings.shared.engineConfigs[engine] ?? .default(for: engine) + } + + return try await registry.createProvider(for: engine, config: engineConfig) + } + // MARK: - Legacy API (Backward Compatible) /// Translates segments using the preferred engine with automatic fallback @@ -399,13 +401,11 @@ actor TranslationService { return true } - do { - let config = TranslationEngineConfig.default(for: engine) - let provider = try await registry.createProvider(for: engine, config: config) - return await provider.checkConnection() - } catch { - logger.error("Failed to create provider for \(engine.rawValue): \(error.localizedDescription)") + guard let provider = try? await resolvedProvider(for: engine) else { + logger.error("Failed to resolve provider for \(engine.rawValue)") return false } + + return await provider.checkConnection() } } diff --git a/ScreenTranslateTests/KeyboardShortcutTests.swift b/ScreenTranslateTests/KeyboardShortcutTests.swift index 5439107..c444b5d 100644 --- a/ScreenTranslateTests/KeyboardShortcutTests.swift +++ b/ScreenTranslateTests/KeyboardShortcutTests.swift @@ -1,4 +1,6 @@ import XCTest +import AppKit +import Carbon.HIToolbox @testable import ScreenTranslate // MARK: - KeyboardShortcut Tests diff --git a/ScreenTranslateTests/ScreenTranslateErrorTests.swift b/ScreenTranslateTests/ScreenTranslateErrorTests.swift index c86011e..0059bc9 100644 --- a/ScreenTranslateTests/ScreenTranslateErrorTests.swift +++ b/ScreenTranslateTests/ScreenTranslateErrorTests.swift @@ -130,7 +130,11 @@ final class ScreenTranslateErrorTests: XCTestCase { } // If this compiles and runs, the error is Sendable - XCTAssertEqual(sendableClosure(), error) + if case .permissionDenied = sendableClosure() { + XCTAssertTrue(true) + } else { + XCTFail("Expected permissionDenied case") + } } // MARK: - CaptureFailureError Helper diff --git a/ScreenTranslateTests/ShortcutRecordingTypeTests.swift b/ScreenTranslateTests/ShortcutRecordingTypeTests.swift index 78d5011..63e044e 100644 --- a/ScreenTranslateTests/ShortcutRecordingTypeTests.swift +++ b/ScreenTranslateTests/ShortcutRecordingTypeTests.swift @@ -1,4 +1,5 @@ import XCTest +import Carbon.HIToolbox @testable import ScreenTranslate // MARK: - ShortcutRecordingType Tests diff --git a/ScreenTranslateTests/TranslationPipelineRegressionTests.swift b/ScreenTranslateTests/TranslationPipelineRegressionTests.swift new file mode 100644 index 0000000..c08f536 --- /dev/null +++ b/ScreenTranslateTests/TranslationPipelineRegressionTests.swift @@ -0,0 +1,70 @@ +import CoreGraphics +import XCTest +@testable import ScreenTranslate + +final class TranslationPipelineRegressionTests: XCTestCase { + @available(macOS 13.0, *) + func testTranslationEngineSourceLocaleLanguageUsesNilForAutoDetect() { + XCTAssertNil(TranslationEngine.sourceLocaleLanguage(for: nil)) + XCTAssertNil(TranslationEngine.sourceLocaleLanguage(for: .auto)) + XCTAssertEqual( + TranslationEngine.sourceLocaleLanguage(for: .japanese)?.minimalIdentifier, + Locale.Language(identifier: "ja").minimalIdentifier + ) + } + + func testPromptDisplayNameUsesHumanReadableLanguageNames() { + XCTAssertEqual(TranslationLanguage.promptDisplayName(for: nil), "Auto Detect") + XCTAssertEqual(TranslationLanguage.promptDisplayName(for: "auto"), "Auto Detect") + XCTAssertEqual(TranslationLanguage.promptDisplayName(for: "zh-Hans"), "Chinese (Simplified)") + XCTAssertEqual(TranslationLanguage.promptDisplayName(for: "ja"), "Japanese") + } + + func testNoiseHeuristicFiltersCoordinateLikeText() { + let tick = TextSegment(text: "12.5%", boundingBox: .zero, confidence: 0.95) + let sentence = TextSegment(text: "Revenue growth", boundingBox: .zero, confidence: 0.95) + let monthTick = TextSegment( + text: "Jan", + boundingBox: CGRect(x: 0.01, y: 0.94, width: 0.05, height: 0.02), + confidence: 0.99 + ) + + XCTAssertTrue(tick.isLikelyTranslationNoise) + XCTAssertTrue(monthTick.isLikelyTranslationNoise) + XCTAssertFalse(sentence.isLikelyTranslationNoise) + } + + func testFilteredForTranslationRemovesNoiseButKeepsContent() { + let segments = [ + TextSegment(text: "100", boundingBox: .zero, confidence: 0.99), + TextSegment(text: "Q4 Revenue increased significantly", boundingBox: .zero, confidence: 0.99), + TextSegment(text: "25%", boundingBox: .zero, confidence: 0.99) + ] + + let result = ScreenAnalysisResult(segments: segments, imageSize: CGSize(width: 1000, height: 800)) + let filtered = result.filteredForTranslation() + + XCTAssertEqual(filtered.segments.count, 1) + XCTAssertEqual(filtered.segments.first?.text, "Q4 Revenue increased significantly") + } + + func testFilteredForTranslationFallsBackWhenEverySegmentLooksLikeNoise() { + let segments = [ + TextSegment( + text: "Jan", + boundingBox: CGRect(x: 0.01, y: 0.94, width: 0.05, height: 0.02), + confidence: 0.99 + ), + TextSegment( + text: "2024", + boundingBox: CGRect(x: 0.95, y: 0.40, width: 0.03, height: 0.02), + confidence: 0.99 + ) + ] + + let result = ScreenAnalysisResult(segments: segments, imageSize: CGSize(width: 1200, height: 800)) + let filtered = result.filteredForTranslation() + + XCTAssertEqual(filtered.segments, segments) + } +}