using System.Text; using System.Windows; using AxCopilot.SDK; using AxCopilot.Services; namespace AxCopilot.Handlers; /// /// L23-4: 한/영 타이핑 오류 교정. "fix" 프리픽스로 사용합니다. /// /// 예: fix gksrmf → 안녕 (영타→한글 변환) /// fix → 클립보드 텍스트 자동 교정 /// Enter → 교정 결과 클립보드 복사 /// public class FixHandler : IActionHandler { public string? Prefix => "fix"; public PluginMetadata Metadata => new( "타이핑 교정", "영타→한글 변환 — 잘못 입력된 영문 타이핑을 한글로 교정", "1.0", "AX"); // ── 두벌식 영→자모 매핑 ────────────────────────────────────────────────── // 소문자 private static readonly Dictionary EngToJamo = new() { {'q', 'ㅂ'}, {'w', 'ㅈ'}, {'e', 'ㄷ'}, {'r', 'ㄱ'}, {'t', 'ㅅ'}, {'y', 'ㅛ'}, {'u', 'ㅕ'}, {'i', 'ㅑ'}, {'o', 'ㅐ'}, {'p', 'ㅔ'}, {'a', 'ㅁ'}, {'s', 'ㄴ'}, {'d', 'ㅇ'}, {'f', 'ㄹ'}, {'g', 'ㅎ'}, {'h', 'ㅗ'}, {'j', 'ㅓ'}, {'k', 'ㅏ'}, {'l', 'ㅣ'}, {'z', 'ㅋ'}, {'x', 'ㅌ'}, {'c', 'ㅊ'}, {'v', 'ㅍ'}, {'b', 'ㅠ'}, {'n', 'ㅜ'}, {'m', 'ㅡ'}, // 대문자 = 소문자와 동일 기본 (Shift 된소리/쌍모음 별도) {'Q', 'ㅃ'}, {'W', 'ㅉ'}, {'E', 'ㄸ'}, {'R', 'ㄲ'}, {'T', 'ㅆ'}, {'Y', 'ㅛ'}, {'U', 'ㅕ'}, {'I', 'ㅑ'}, {'O', 'ㅒ'}, {'P', 'ㅖ'}, {'A', 'ㅁ'}, {'S', 'ㄴ'}, {'D', 'ㅇ'}, {'F', 'ㄹ'}, {'G', 'ㅎ'}, {'H', 'ㅗ'}, {'J', 'ㅓ'}, {'K', 'ㅏ'}, {'L', 'ㅣ'}, {'Z', 'ㅋ'}, {'X', 'ㅌ'}, {'C', 'ㅊ'}, {'V', 'ㅍ'}, {'B', 'ㅠ'}, {'N', 'ㅜ'}, {'M', 'ㅡ'}, }; // ── 초성 배열 (19개) ────────────────────────────────────────────────────── private static readonly char[] Choseong = ['ㄱ','ㄲ','ㄴ','ㄷ','ㄸ','ㄹ','ㅁ','ㅂ','ㅃ','ㅅ','ㅆ','ㅇ','ㅈ','ㅉ','ㅊ','ㅋ','ㅌ','ㅍ','ㅎ']; // ── 중성 배열 (21개) ────────────────────────────────────────────────────── private static readonly char[] Jungseong = ['ㅏ','ㅐ','ㅑ','ㅒ','ㅓ','ㅔ','ㅕ','ㅖ','ㅗ','ㅘ','ㅙ','ㅚ','ㅛ','ㅜ','ㅝ','ㅞ','ㅟ','ㅠ','ㅡ','ㅢ','ㅣ']; // ── 종성 배열 (28개, 0=없음) ───────────────────────────────────────────── private static readonly char[] Jongseong = ['\0','ㄱ','ㄲ','ㄳ','ㄴ','ㄵ','ㄶ','ㄷ','ㄹ','ㄺ','ㄻ','ㄼ','ㄽ','ㄾ','ㄿ','ㅀ','ㅁ','ㅂ','ㅄ','ㅅ','ㅆ','ㅇ','ㅈ','ㅊ','ㅋ','ㅌ','ㅍ','ㅎ']; // ── 복합 모음 ───────────────────────────────────────────────────────────── private static readonly Dictionary<(char, char), char> CompoundVowel = new() { {('ㅗ','ㅏ'), 'ㅘ'}, {('ㅗ','ㅐ'), 'ㅙ'}, {('ㅗ','ㅣ'), 'ㅚ'}, {('ㅜ','ㅓ'), 'ㅝ'}, {('ㅜ','ㅔ'), 'ㅞ'}, {('ㅜ','ㅣ'), 'ㅟ'}, {('ㅡ','ㅣ'), 'ㅢ'}, }; // ── 복합 종성 ───────────────────────────────────────────────────────────── private static readonly Dictionary<(char, char), char> CompoundJong = new() { {('ㄱ','ㅅ'), 'ㄳ'}, {('ㄴ','ㅈ'), 'ㄵ'}, {('ㄴ','ㅎ'), 'ㄶ'}, {('ㄹ','ㄱ'), 'ㄺ'}, {('ㄹ','ㅁ'), 'ㄻ'}, {('ㄹ','ㅂ'), 'ㄼ'}, {('ㄹ','ㅅ'), 'ㄽ'}, {('ㄹ','ㅌ'), 'ㄾ'}, {('ㄹ','ㅍ'), 'ㄿ'}, {('ㄹ','ㅎ'), 'ㅀ'}, {('ㅂ','ㅅ'), 'ㅄ'}, }; // 복합 종성 분리 (모음이 올 때 jong → cho + remain) private static readonly Dictionary SplitJong = new() { {'ㄳ', ('ㄱ','ㅅ')}, {'ㄵ', ('ㄴ','ㅈ')}, {'ㄶ', ('ㄴ','ㅎ')}, {'ㄺ', ('ㄹ','ㄱ')}, {'ㄻ', ('ㄹ','ㅁ')}, {'ㄼ', ('ㄹ','ㅂ')}, {'ㄽ', ('ㄹ','ㅅ')}, {'ㄾ', ('ㄹ','ㅌ')}, {'ㄿ', ('ㄹ','ㅍ')}, {'ㅀ', ('ㄹ','ㅎ')}, {'ㅄ', ('ㅂ','ㅅ')}, }; // ── 인덱스 헬퍼 ────────────────────────────────────────────────────────── private static int ChoIdx(char c) => Array.IndexOf(Choseong, c); private static int JungIdx(char c) => Array.IndexOf(Jungseong, c); private static int JongIdx(char c) => Array.IndexOf(Jongseong, c); private static bool IsVowel(char jamo) => JungIdx(jamo) >= 0; private static bool IsConsonant(char jamo) => ChoIdx(jamo) >= 0 || JongIdx(jamo) > 0; private static char MakeSyllable(int cho, int jung, int jong) => (char)(0xAC00 + cho * 21 * 28 + jung * 28 + jong); // ── 한글 조합기 (두벌식 상태 기계) ─────────────────────────────────────── private sealed class HangulComposer { private int _cho = -1; private int _jung = -1; private int _jong = -1; private readonly StringBuilder _sb = new(); public string Result => _sb.ToString(); public void Flush() { if (_cho < 0) return; if (_jung < 0) { // 초성만 _sb.Append(Choseong[_cho]); } else { _sb.Append(MakeSyllable(_cho, _jung, _jong < 0 ? 0 : _jong)); } _cho = _jung = _jong = -1; } public void Feed(char jamo) { if (IsVowel(jamo)) { FeedVowel(jamo); } else { FeedConsonant(jamo); } } private void FeedVowel(char v) { var vi = JungIdx(v); if (_cho < 0) { // 초성 없음 → ㅇ + 모음 _sb.Append(MakeSyllable(ChoIdx('ㅇ'), vi, 0)); return; } if (_jung < 0) { // 초성만 있음 → 중성 결합 _jung = vi; return; } // 초성+중성 있음 if (_jong < 0) { // 복합 모음 시도 var curVowel = Jungseong[_jung]; if (CompoundVowel.TryGetValue((curVowel, v), out var compound)) { _jung = JungIdx(compound); } else { // 현재 음절 확정, 새 음절 시작 (ㅇ + 모음) Flush(); _cho = ChoIdx('ㅇ'); _jung = vi; } return; } // 초성+중성+종성 있음 → 종성을 새 음절의 초성으로 var jongChar = Jongseong[_jong]; if (SplitJong.TryGetValue(jongChar, out var split)) { // 복합 종성: 앞 자음은 종성, 뒷 자음은 새 초성 var newCho = ChoIdx(split.Second); if (newCho < 0) newCho = 0; var remainJong = JongIdx(split.First); // 현재 음절 (jong=split.First) _sb.Append(MakeSyllable(_cho, _jung, remainJong)); _cho = newCho; _jung = vi; _jong = -1; } else { // 단일 종성 → 새 초성으로 var newCho = ChoIdx(jongChar); if (newCho < 0) newCho = 0; _sb.Append(MakeSyllable(_cho, _jung, 0)); _cho = newCho; _jung = vi; _jong = -1; } } private void FeedConsonant(char c) { var ci = ChoIdx(c); if (_cho < 0) { // 처음 자음 _cho = ci >= 0 ? ci : 0; return; } if (_jung < 0) { // 초성만 있음 → 이전 초성 출력, 새 초성 _sb.Append(Choseong[_cho]); _cho = ci >= 0 ? ci : 0; _jung = -1; _jong = -1; return; } if (_jong < 0) { // 초성+중성 → 종성 후보 _jong = JongIdx(c); if (_jong < 0) _jong = 0; // 종성에 없는 자음은 그냥 처리 if (_jong == 0) { // 종성에 들어갈 수 없는 자음(ㄸ, ㅃ, ㅉ) Flush(); _cho = ci >= 0 ? ci : 0; _jung = -1; _jong = -1; } return; } // 초성+중성+종성 있음 var curJongChar = Jongseong[_jong]; if (CompoundJong.TryGetValue((curJongChar, c), out var compJ)) { // 복합 종성 가능 var cji = JongIdx(compJ); if (cji > 0) { _jong = cji; return; } } // 복합 종성 불가 → 현재 음절 확정, 새 초성 Flush(); _cho = ci >= 0 ? ci : 0; _jung = -1; _jong = -1; } } // ── 영타→한글 변환 ──────────────────────────────────────────────────────── private static string EngToKorean(string input) { var composer = new HangulComposer(); var sb = new StringBuilder(); foreach (var ch in input) { if (EngToJamo.TryGetValue(ch, out var jamo)) { if (composer.Result.Length > 0 || jamo != '\0') { // 현재 변환 중인 조합기에 피드 } composer.Feed(jamo); } else { // 변환 불가 문자 → 조합기 플러시 후 그대로 var cur = composer.Result; // 지금까지 쌓인 결과 덤프 composer.Feed('\0'); // 플러시 트리거 안 됨 → 직접 Flush // 아래 로직: 조합기는 Flush()로만 비워짐 sb.Append(ch); } } // 위 로직을 단순화: 문자별 처리 return ConvertEngToKor(input); } private static string ConvertEngToKor(string input) { // 먼저 자모 문자열로 변환 var jamoSeq = new List(); foreach (var ch in input) { if (EngToJamo.TryGetValue(ch, out var jamo)) jamoSeq.Add(jamo); else jamoSeq.Add(ch); // 변환 불가 문자는 그대로 } // 자모 시퀀스를 한글 음절로 조합 var result = new StringBuilder(); var i = 0; while (i < jamoSeq.Count) { var ch = jamoSeq[i]; // 변환 불가 문자 (공백, 숫자, 특수문자 등) if (!IsKorJamo(ch)) { result.Append(ch); i++; continue; } // 자모 덩어리 추출 var jamoBlock = new List(); var j = i; while (j < jamoSeq.Count && IsKorJamo(jamoSeq[j])) jamoBlock.Add(jamoSeq[j++]); // 자모 블록을 한글로 조합 result.Append(ComposeHangul(jamoBlock)); i = j; } return result.ToString(); } private static bool IsKorJamo(char c) { return (c >= 'ㄱ' && c <= 'ㅎ') || (c >= 'ㅏ' && c <= 'ㅣ'); } private static string ComposeHangul(List jamos) { var sb = new StringBuilder(); var idx = 0; while (idx < jamos.Count) { var c = jamos[idx]; if (IsVowel(c)) { // 단독 모음 → ㅇ + 모음 sb.Append(MakeSyllable(ChoIdx('ㅇ'), JungIdx(c), 0)); idx++; continue; } // 자음: 초성 후보 var cho = c; var choI = ChoIdx(cho); if (choI < 0) { sb.Append(c); idx++; continue; } idx++; if (idx >= jamos.Count || IsConsonantOnly(jamos[idx])) { // 단독 초성 sb.Append(cho); continue; } // 중성 var v1 = jamos[idx]; var jungI = JungIdx(v1); if (jungI < 0) { sb.Append(cho); continue; } idx++; // 복합 모음 시도 if (idx < jamos.Count && IsVowel(jamos[idx])) { if (CompoundVowel.TryGetValue((v1, jamos[idx]), out var cv)) { jungI = JungIdx(cv); idx++; } } // 종성 후보 if (idx >= jamos.Count) { sb.Append(MakeSyllable(choI, jungI, 0)); continue; } var next = jamos[idx]; if (IsVowel(next)) { sb.Append(MakeSyllable(choI, jungI, 0)); continue; } // 종성 자음 var jongI = JongIdx(next); if (jongI <= 0) { sb.Append(MakeSyllable(choI, jungI, 0)); continue; } idx++; // 다음 모음 있으면 종성→초성 이동 if (idx < jamos.Count && IsVowel(jamos[idx])) { // 복합 종성 분리 확인 if (idx + 1 < jamos.Count && IsVowel(jamos[idx])) { // 분리 없음: next 자음 → 다음 음절 초성 } sb.Append(MakeSyllable(choI, jungI, 0)); idx--; // next 자음을 다음 루프에서 초성으로 사용 continue; } // 복합 종성 시도 if (idx < jamos.Count && !IsVowel(jamos[idx])) { var next2 = jamos[idx]; var jongChar = Jongseong[jongI]; if (CompoundJong.TryGetValue((jongChar, next2), out var cj)) { var cji = JongIdx(cj); if (cji > 0) { // 다음에 모음이 있으면 복합 종성 분리 if (idx + 1 < jamos.Count && IsVowel(jamos[idx + 1])) { sb.Append(MakeSyllable(choI, jungI, jongI)); // next2는 다음 음절 초성으로 idx--; // next2를 다시 처리 idx++; continue; } jongI = cji; idx++; } } // 다음에 모음이 있으면 종성→초성 if (idx < jamos.Count && IsVowel(jamos[idx])) { sb.Append(MakeSyllable(choI, jungI, 0)); idx -= 2; idx++; continue; } } sb.Append(MakeSyllable(choI, jungI, jongI)); } return sb.ToString(); } // 초성으로만 사용 가능한 자음인지 (된소리 = 종성 불가) private static bool IsConsonantOnly(char c) { if (!IsKorJamo(c)) return false; if (IsVowel(c)) return false; return JongIdx(c) <= 0; } // ── GetItemsAsync ───────────────────────────────────────────────────────── public Task> GetItemsAsync(string query, CancellationToken ct) { var q = query.Trim(); var items = new List(); string inputText; if (string.IsNullOrWhiteSpace(q)) { // 클립보드에서 읽기 string? clipText = null; try { Application.Current.Dispatcher.Invoke(() => { if (Clipboard.ContainsText()) clipText = Clipboard.GetText(); }); } catch { } if (string.IsNullOrWhiteSpace(clipText)) { items.Add(new LauncherItem("한/영 타이핑 교정", "fix <영타 텍스트> 또는 클립보드에 텍스트 복사 후 fix 입력", null, null, Symbol: "\uE8AC")); items.Add(new LauncherItem("예: fix gksrmf", "→ 안녕 (영타→한글)", null, null, Symbol: "\uE8AC")); return Task.FromResult>(items); } inputText = clipText; } else { inputText = q; } var converted = ConvertEngToKor(inputText); if (converted == inputText) { items.Add(new LauncherItem("변환할 영타 오류가 없습니다", $"입력: {inputText}", null, null, Symbol: "\uE8AC")); } else { items.Add(new LauncherItem( converted, $"영타 교정 결과 · Enter: 클립보드 복사", null, ("copy", converted), Symbol: "\uE8AC")); items.Add(new LauncherItem( $"원본: {(inputText.Length > 50 ? inputText[..50] + "…" : inputText)}", "", null, null, Symbol: "\uE8AC")); } return Task.FromResult>(items); } // ── ExecuteAsync ────────────────────────────────────────────────────────── public Task ExecuteAsync(LauncherItem item, CancellationToken ct) { if (item.Data is ("copy", string text)) { try { Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text)); NotificationService.Notify("타이핑 교정", "교정 결과를 클립보드에 복사했습니다."); } catch { } } return Task.CompletedTask; } }