package translit import ( "strings" "unicode" "golang.org/x/text/unicode/norm" ) // https://en.wikipedia.org/wiki/Hangul_Jamo_%28Unicode_block%29 var jamoBlock = &unicode.RangeTable{ R16: []unicode.Range16{{ Lo: 0x1100, Hi: 0x11FF, Stride: 1, }}, } // https://en.wikipedia.org/wiki/Hangul_Syllables var syllablesBlock = &unicode.RangeTable{ R16: []unicode.Range16{{ Lo: 0xAC00, Hi: 0xD7A3, Stride: 1, }}, } // https://en.wikipedia.org/wiki/Hangul_Compatibility_Jamo var compatJamoBlock = &unicode.RangeTable{ R16: []unicode.Range16{{ Lo: 0x3131, Hi: 0x318E, Stride: 1, }}, } // KoreanTranslit implements transliteration for Korean. // // This was translated to Go from the code in https://codeberg.org/Freeyourgadget/Gadgetbridge type KoreanTranslit struct{} func (KoreanTranslit) Init() {} // User input consisting of isolated jamo is usually mapped to the KS X 1001 compatibility // block, but jamo resulting from decomposed syllables are mapped to the modern one. This // function maps compat jamo to modern ones where possible and returns all other characters // unmodified. // // https://en.wikipedia.org/wiki/Hangul_Compatibility_Jamo // https://en.wikipedia.org/wiki/Hangul_Jamo_%28Unicode_block%29 func decompatJamo(jamo rune) rune { // KS X 1001 Hangul filler, not used in modern Unicode. A useful landmark in the // compatibility jamo block. // https://en.wikipedia.org/wiki/KS_X_1001#Hangul_Filler var hangulFiller rune = 0x3164 // Ignore characters outside compatibility jamo block if !unicode.In(jamo, compatJamoBlock) { return jamo } // Vowels are contiguous, in the same order, and unambiguous so it's a simple offset. if jamo >= 0x314F && jamo < hangulFiller { return jamo - 0x1FEE } // Consonants are organized differently. No clean way to do this. // The compatibility jamo block doesn't distinguish between Choseong (leading) and Jongseong // (final) positions, but the modern block does. We map to Choseong here. switch jamo { case 0x3131: return 0x1100 // ㄱ case 0x3132: return 0x1101 // ㄲ case 0x3134: return 0x1102 // ㄴ case 0x3137: return 0x1103 // ㄷ case 0x3138: return 0x1104 // ㄸ case 0x3139: return 0x1105 // ㄹ case 0x3141: return 0x1106 // ㅁ case 0x3142: return 0x1107 // ㅂ case 0x3143: return 0x1108 // ㅃ case 0x3145: return 0x1109 // ㅅ case 0x3146: return 0x110A // ㅆ case 0x3147: return 0x110B // ㅇ case 0x3148: return 0x110C // ㅈ case 0x3149: return 0x110D // ㅉ case 0x314A: return 0x110E // ㅊ case 0x314B: return 0x110F // ㅋ case 0x314C: return 0x1110 // ㅌ case 0x314D: return 0x1111 // ㅍ case 0x314E: return 0x1112 // ㅎ } // The rest of the compatibility block consists of archaic compounds that are // unlikely to be encountered in modern systems. Just leave them alone. return jamo } // Transliterates one jamo at a time. // Does nothing if it isn't in the modern jamo block. func translitSingleJamo(jamo rune) string { jamo = decompatJamo(jamo) switch jamo { // Choseong (leading position consonants) case 0x1100: return "g" // ㄱ case 0x1101: return "kk" // ㄲ case 0x1102: return "n" // ㄴ case 0x1103: return "d" // ㄷ case 0x1104: return "tt" // ㄸ case 0x1105: return "r" // ㄹ case 0x1106: return "m" // ㅁ case 0x1107: return "b" // ㅂ case 0x1108: return "pp" // ㅃ case 0x1109: return "s" // ㅅ case 0x110A: return "ss" // ㅆ case 0x110B: return "" // ㅇ case 0x110C: return "j" // ㅈ case 0x110D: return "jj" // ㅉ case 0x110E: return "ch" // ㅊ case 0x110F: return "k" // ㅋ case 0x1110: return "t" // ㅌ case 0x1111: return "p" // ㅍ case 0x1112: return "h" // ㅎ // Jungseong (vowels) case 0x1161: return "a" // ㅏ case 0x1162: return "ae" // ㅐ case 0x1163: return "ya" // ㅑ case 0x1164: return "yae" // ㅒ case 0x1165: return "eo" // ㅓ case 0x1166: return "e" // ㅔ case 0x1167: return "yeo" // ㅕ case 0x1168: return "ye" // ㅖ case 0x1169: return "o" // ㅗ case 0x116A: return "wa" // ㅘ case 0x116B: return "wae" // ㅙ case 0x116C: return "oe" // ㅚ case 0x116D: return "yo" // ㅛ case 0x116E: return "u" // ㅜ case 0x116F: return "wo" // ㅝ case 0x1170: return "we" // ㅞ case 0x1171: return "wi" // ㅟ case 0x1172: return "yu" // ㅠ case 0x1173: return "eu" // ㅡ case 0x1174: return "ui" // ㅢ case 0x1175: return "i" // ㅣ // Jongseong (final position consonants) case 0x11A8: return "k" // ㄱ case 0x11A9: return "k" // ㄲ case 0x11AB: return "n" // ㄴ case 0x11AE: return "t" // ㄷ case 0x11AF: return "l" // ㄹ case 0x11B7: return "m" // ㅁ case 0x11B8: return "p" // ㅂ case 0x11BA: return "t" // ㅅ case 0x11BB: return "t" // ㅆ case 0x11BC: return "ng" // ㅇ case 0x11BD: return "t" // ㅈ case 0x11BE: return "t" // ㅊ case 0x11BF: return "k" // ㅋ case 0x11C0: return "t" // ㅌ case 0x11C1: return "p" // ㅍ case 0x11C2: return "t" // ㅎ } return string(jamo) } // Some combinations of ending jamo in one syllable and initial jamo in the next are romanized // irregularly. These exceptions are called "special provisions". In cases where multiple // romanizations are permitted, we use the one that's least commonly used elsewhere. // // Returns empty strring and false if either character is not in the modern jamo block, // or if there is no special provision for that pair of jamo. func translitSpecialProvisions(previousEnding rune, nextInitial rune) (string, bool) { // Return false if previousEnding not in modern jamo block if !unicode.In(previousEnding, jamoBlock) { return "", false } // Return false if nextInitial not in modern jamo block if !unicode.In(nextInitial, jamoBlock) { return "", false } // Jongseong (final position) ㅎ has a number of special provisions. if previousEnding == 0x11C2 { switch nextInitial { case 0x110B: return "h", true // ㅇ case 0x1100: return "k", true // ㄱ case 0x1102: return "nn", true // ㄴ case 0x1103: return "t", true // ㄷ case 0x1105: return "nn", true // ㄹ case 0x1106: return "nm", true // ㅁ case 0x1107: return "p", true // ㅂ case 0x1109: return "hs", true // ㅅ case 0x110C: return "ch", true // ㅈ case 0x1112: return "t", true // ㅎ default: return "", false } } // Otherwise, special provisions are denser when grouped by the second jamo. switch nextInitial { case 0x1100: // ㄱ switch previousEnding { case 0x11AB: return "n-g", true // ㄴ default: return "", false } case 0x1102: // ㄴ switch previousEnding { case 0x11A8: return "ngn", true // ㄱ case 0x11AE: fallthrough // ㄷ case 0x11BA: fallthrough // ㅅ case 0x11BD: fallthrough // ㅈ case 0x11BE: fallthrough // ㅊ case 0x11C0: // ㅌ return "nn", true case 0x11AF: return "ll", true // ㄹ case 0x11B8: return "mn", true // ㅂ default: return "", false } case 0x1105: // ㄹ switch previousEnding { case 0x11A8: fallthrough // ㄱ case 0x11AB: fallthrough // ㄴ case 0x11AF: // ㄹ return "ll", true case 0x11AE: fallthrough // ㄷ case 0x11BA: fallthrough // ㅅ case 0x11BD: fallthrough // ㅈ case 0x11BE: fallthrough // ㅊ case 0x11C0: // ㅌ return "nn", true case 0x11B7: fallthrough // ㅁ case 0x11B8: // ㅂ return "mn", true case 0x11BC: return "ngn", true // ㅇ default: return "", false } case 0x1106: // ㅁ switch previousEnding { case 0x11A8: return "ngm", true // ㄱ case 0x11AE: fallthrough // ㄷ case 0x11BA: fallthrough // ㅅ case 0x11BD: fallthrough // ㅈ case 0x11BE: fallthrough // ㅊ case 0x11C0: // ㅌ return "nm", true case 0x11B8: return "mm", true // ㅂ default: return "", false } case 0x110B: // ㅇ switch previousEnding { case 0x11A8: return "g", true // ㄱ case 0x11AE: return "d", true // ㄷ case 0x11AF: return "r", true // ㄹ case 0x11B8: return "b", true // ㅂ case 0x11BA: return "s", true // ㅅ case 0x11BC: return "ng-", true // ㅇ case 0x11BD: return "j", true // ㅈ case 0x11BE: return "ch", true // ㅊ default: return "", false } case 0x110F: // ㅋ switch previousEnding { case 0x11A8: return "k-k", true // ㄱ default: return "", false } case 0x1110: // ㅌ switch previousEnding { case 0x11AE: fallthrough // ㄷ case 0x11BA: fallthrough // ㅅ case 0x11BD: fallthrough // ㅈ case 0x11BE: fallthrough // ㅊ case 0x11C0: // ㅌ return "t-t", true default: return "", false } case 0x1111: // ㅍ switch previousEnding { case 0x11B8: return "p-p", true // ㅂ default: return "", false } default: return "", false } } // Decompose a syllable into several jamo. Does nothing if that isn't possible. func decompose(syllable rune) string { return norm.NFD.String(string(syllable)) } // Transliterate any Hangul in the given string. // Leaves any non-Hangul characters unmodified. func (kt *KoreanTranslit) Transliterate(s string) string { if len(s) == 0 { return s } builder := &strings.Builder{} nextInitialJamoConsumed := false for i, syllable := range s { // If character not in blocks, leave it unmodified if !unicode.In(syllable, jamoBlock, syllablesBlock, compatJamoBlock) { builder.WriteRune(syllable) continue } jamo := decompose(syllable) for j, char := range jamo { // If we already transliterated the first jamo of this syllable as part of a special // provision, skip it. Otherwise, handle it in the unconditional else branch. if j == 0 && nextInitialJamoConsumed { nextInitialJamoConsumed = false continue } // If this is the last jamo of this syllable and not the last syllable of the // string, check for special provisions. If the next char is whitespace or not // Hangul, run translitSpecialProvisions() should return no value. if j == len(jamo)-1 && i < len(s)-1 { nextSyllable := s[i+1] nextJamo := decompose(rune(nextSyllable))[0] // Attempt to handle special provision specialProvision, ok := translitSpecialProvisions(char, rune(nextJamo)) if ok { builder.WriteString(specialProvision) nextInitialJamoConsumed = true } else { // Not a special provision, transliterate normally builder.WriteString(translitSingleJamo(char)) } continue } // Transliterate normally builder.WriteString(translitSingleJamo(char)) } } return builder.String() }