diff --git a/Core/Sources/Core/InputUtils/KatakanaEnglishCandidates.swift b/Core/Sources/Core/InputUtils/KatakanaEnglishCandidates.swift new file mode 100644 index 00000000..86243a3c --- /dev/null +++ b/Core/Sources/Core/InputUtils/KatakanaEnglishCandidates.swift @@ -0,0 +1,135 @@ +/// カタカナで入力した外来語から、対応する英単語の綴りを候補に追加する。 +/// +/// 機械的なローマ字化ではなく、一般的な英語表記だけを明示的に登録する。 +enum KatakanaEnglishCandidates { + private static let words: [String: String] = [ + // 日常で使う物・場所 + "ベッド": "bed", + "ボトル": "bottle", + "カメラ": "camera", + "カード": "card", + "コーヒー": "coffee", + "ドア": "door", + "ドレス": "dress", + "エレベーター": "elevator", + "フード": "food", + "ホテル": "hotel", + "アイスクリーム": "ice cream", + "ジャケット": "jacket", + "キッチン": "kitchen", + "ランチ": "lunch", + "マップ": "map", + "メニュー": "menu", + "ミラー": "mirror", + "オフィス": "office", + "パスポート": "passport", + "ペン": "pen", + "ピザ": "pizza", + "レストラン": "restaurant", + "シャツ": "shirt", + "ショップ": "shop", + "ソファ": "sofa", + "スーツ": "suit", + "タクシー": "taxi", + "チケット": "ticket", + "トイレ": "toilet", + "トレイン": "train", + "バス": "bus", + "ウォーター": "water", + "ウィンドウ": "window", + + // デジタル作業 + "アクセス": "access", + "アカウント": "account", + "アプリ": "app", + "アップデート": "update", + "アップロード": "upload", + "ブラウザ": "browser", + "クリック": "click", + "コード": "code", + "コンテンツ": "content", + "データ": "data", + "デバッグ": "debug", + "デバイス": "device", + "ディスプレイ": "display", + "ドキュメント": "document", + "ダウンロード": "download", + "エラー": "error", + "ファイル": "file", + "フォルダ": "folder", + "インストール": "install", + "インターフェース": "interface", + "インターネット": "internet", + "キーボード": "keyboard", + "リンク": "link", + "ログイン": "login", + "マウス": "mouse", + "ネットワーク": "network", + "オプション": "option", + "パスワード": "password", + "プライバシー": "privacy", + "プログラム": "program", + "プロジェクト": "project", + "リリース": "release", + "セキュリティ": "security", + "サーバー": "server", + "サービス": "service", + "ソフトウェア": "software", + "スマートフォン": "smartphone", + "システム": "system", + "タブ": "tab", + "テキスト": "text", + "ユーザー": "user", + "バージョン": "version", + "ウェブ": "web", + "ウェブサイト": "website", + + // 仕事・コミュニケーション + "アジェンダ": "agenda", + "アポイントメント": "appointment", + "ミーティング": "meeting", + "メッセージ": "message", + "メール": "email", + "プレゼンテーション": "presentation", + "レポート": "report", + "スケジュール": "schedule", + "タスク": "task", + "チーム": "team", + + // よく使う一般語 + "アイデア": "idea", + "アイコン": "icon", + "イメージ": "image", + "イベント": "event", + "ゲーム": "game", + "グループ": "group", + "キーワード": "keyword", + "ラベル": "label", + "レイアウト": "layout", + "リスト": "list", + "モデル": "model", + "ニュース": "news", + "ページ": "page", + "パターン": "pattern", + "プラン": "plan", + "ポイント": "point", + "ポリシー": "policy", + "レスポンス": "response", + "ルール": "rule", + "サンプル": "sample", + "サイズ": "size", + "スタイル": "style", + "タイトル": "title", + "トピック": "topic", + "タイプ": "type", + "ワーク": "work" + ] + + static func variants(for reading: String) -> [String] { + guard let word = Self.words[reading] else { + return [] + } + let title = word.prefix(1).uppercased() + word.dropFirst() + return title == word ? [word] : [word, title] + } +} diff --git a/Core/Sources/Core/InputUtils/SegmentsManager.swift b/Core/Sources/Core/InputUtils/SegmentsManager.swift index 3188ca36..b86303c8 100644 --- a/Core/Sources/Core/InputUtils/SegmentsManager.swift +++ b/Core/Sources/Core/InputUtils/SegmentsManager.swift @@ -564,7 +564,7 @@ public final class SegmentsManager { let leftSideContext = forcedLeftSideContext ?? self.getCleanLeftSideContext(maxCount: ContextLength.conversion) let rightSideContext = forcedRightSideContext ?? self.getCleanRightSideContext(maxCount: ContextLength.conversion) - let result = self.kanaKanjiConverter.requestCandidates( + var result = self.kanaKanjiConverter.requestCandidates( self.composingText, options: options( leftSideContext: leftSideContext, @@ -574,6 +574,27 @@ public final class SegmentsManager { requireEnglishPrediction: Config.DebugPredictiveTyping().value ? .manualMix : .disabled ) ) + if self.composingText.isAtEndIndex { + let reading = self.composingText.convertTarget.toKatakana() + let englishWords = KatakanaEnglishCandidates.variants(for: reading) + if !englishWords.isEmpty { + var seen = Set(result.mainResults.map(\.text)) + let candidates = englishWords.compactMap { word -> Candidate? in + guard seen.insert(word).inserted else { + return nil + } + return Candidate( + text: word, + value: -18, + composingCount: .surfaceCount(self.composingText.convertTarget.count), + lastMid: MIDData.一般.mid, + data: [.init(word: word, ruby: reading, cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -18)], + isLearningTarget: false + ) + } + result.mainResults.insert(contentsOf: candidates, at: min(5, result.mainResults.count)) + } + } self.rawCandidates = result } diff --git a/Core/Tests/CoreTests/InputUtilsTests/KatakanaEnglishCandidatesTests.swift b/Core/Tests/CoreTests/InputUtilsTests/KatakanaEnglishCandidatesTests.swift new file mode 100644 index 00000000..aa6795b0 --- /dev/null +++ b/Core/Tests/CoreTests/InputUtilsTests/KatakanaEnglishCandidatesTests.swift @@ -0,0 +1,40 @@ +@testable import Core +import Foundation +import KanaKanjiConverterModuleWithDefaultDictionary +import Testing + +struct KatakanaEnglishCandidatesTests { + @Test func returnsLowercaseAndSentenceCaseVariants() { + #expect(KatakanaEnglishCandidates.variants(for: "セキュリティ") == ["security", "Security"]) + #expect(KatakanaEnglishCandidates.variants(for: "ディスプレイ") == ["display", "Display"]) + #expect(KatakanaEnglishCandidates.variants(for: "スマートフォン") == ["smartphone", "Smartphone"]) + #expect(KatakanaEnglishCandidates.variants(for: "ベッド") == ["bed", "Bed"]) + #expect(KatakanaEnglishCandidates.variants(for: "マウス") == ["mouse", "Mouse"]) + #expect(KatakanaEnglishCandidates.variants(for: "キーボード") == ["keyboard", "Keyboard"]) + #expect(KatakanaEnglishCandidates.variants(for: "メール") == ["email", "Email"]) + } + + @Test func doesNotGuessUnknownKatakanaWords() { + #expect(KatakanaEnglishCandidates.variants(for: "アズーキー").isEmpty) + } + + @MainActor @Test func insertsEnglishSpellingIntoConversionCandidates() { + let manager = SegmentsManager( + kanaKanjiConverter: .withDefaultDictionary(), + applicationDirectoryURL: URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true), + containerURL: nil, + context: .init(useZenzai: false) + ) + manager.insertAtCursorPosition("セキュリティ", inputStyle: .direct) + manager.requestSetCandidateWindowState(visible: true) + + switch manager.getCurrentCandidateWindow(inputState: .selecting) { + case .selecting(let candidates, _): + let texts = candidates.map(\.text) + #expect(texts.contains("security")) + #expect(texts.contains("Security")) + case .hidden, .composing: + Issue.record("Expected conversion candidates.") + } + } +}