hangul.js (5738B)
1 // SPDX-License-Identifier: AGPL-3.0-or-later 2 // Copyright (C) 2009 Dan Bravender 3 // Modifications Copyright (c) 2026 Brook Jeynes <me@brookjeynes.dev> 4 /* Many of the calculations used within this file can be referenced back to 5 * http://www.kfunigraz.ac.at/~katzer/korean_hangul_unicode.html 6 * 7 * Unfortunately, this site is now dead. I've gone ahead and archived a version 8 * of it at ./resources/korean_hangul_syllabary_in_unicode_archive.html for 9 * future reference. 10 */ 11 export const hangulUnicodeRange = { 12 lower: 0xAC00, 13 upper: 0xD7A3, 14 }; 15 const hangulTailUnicodeOffset = 0x11A8; 16 const hangulVowelUnicodeOffset = 0x314f; 17 const hangulLeadUnicodeOffset = 0x1100; 18 const hangulEmptyConsonant = 0x11A7; 19 /* Used to track modifications that have been made to characters. It keeps 20 * track of characters' original padchims (for ㄷ -> ㄹ irregulars) and if the 21 * character has no padchim but should be treated as if it does (for ㅅ 22 * irregulars). When substrings are extracted the Geulja class keeps these 23 * markers for the last character only. 24 */ 25 export class Geulja { 26 constructor(value) { 27 this.value = ""; 28 this.length = 0; 29 this.hidden_padchim = false; 30 this.original_padchim = null; 31 this.value = value; 32 this.length = value.length; 33 return new Proxy(this, { 34 get: (obj, key) => { 35 if (typeof (key) === "string" && (Number.isInteger(Number(key)))) { 36 const index = Number(key); 37 if (index < 0 || index >= obj.value.length) { 38 return undefined; 39 } 40 return obj.getItem(index); 41 } 42 else { 43 return obj[key]; 44 } 45 }, 46 }); 47 } 48 toString() { 49 return this.value; 50 } 51 slice(start, end) { 52 return this.value.slice(start, end); 53 } 54 substring(start, end) { 55 return this.value.substring(start, end); 56 } 57 split(separator, limit) { 58 return this.value.split(separator, limit); 59 } 60 endsWith(searchString) { 61 return this.value.endsWith(searchString); 62 } 63 startsWith(searchString, position) { 64 return this.value.startsWith(searchString, position); 65 } 66 getItem(index) { 67 const geulja = new Geulja(this.value.charAt(index)); 68 // Only keep the hidden padchim marker for the last item. 69 if (index === this.value.length - 1) { 70 geulja.hidden_padchim = this.hidden_padchim; 71 geulja.original_padchim = this.original_padchim; 72 } 73 return geulja; 74 } 75 } 76 export function isHangul(character) { 77 if (character.length !== 1) { 78 throw new Error("isHangeul only checks characters with a length of 1"); 79 } 80 const code = character.charCodeAt(0); 81 return code >= hangulUnicodeRange.lower && code <= hangulUnicodeRange.upper; 82 } 83 export function findVowelToAppend(geulja) { 84 const reversed = [...`${geulja}`].reverse(); 85 for (const char of reversed) { 86 if (["뜨", "쓰", "트"].includes(char)) { 87 return "어"; 88 } 89 if (getVowel(char) === "ㅡ" && !getPadchim(char)) { 90 continue; 91 } 92 if (["ㅗ", "ㅏ", "ㅑ"].includes(getVowel(char))) { 93 return "아"; 94 } 95 else { 96 return "어"; 97 } 98 } 99 return "어"; 100 } 101 /** 102 * Assembly a set of Jamo characters. 103 */ 104 export function join(lead, vowel, padchim = null) { 105 const lead_offset = lead.charCodeAt(0) - hangulLeadUnicodeOffset; 106 const vowel_offset = vowel.charCodeAt(0) - hangulVowelUnicodeOffset; 107 const padchim_offset = padchim ? padchim.charCodeAt(0) - hangulTailUnicodeOffset : -1; 108 return String.fromCharCode(padchim_offset + vowel_offset * 28 + lead_offset * 588 + hangulUnicodeRange.lower + 1); 109 } 110 export function getLead(geulja) { 111 const character_code = `${geulja}`.charCodeAt(0); 112 const relative_lead_code = Math.floor((character_code - hangulUnicodeRange.lower) / 588); 113 const lead_code = (relative_lead_code + hangulLeadUnicodeOffset); 114 return String.fromCharCode(lead_code); 115 } 116 export function getVowel(geulja) { 117 const padchim = getPadchim(geulja); 118 const padchim_offset = padchim === null ? -1 : padchim.charCodeAt(0) - hangulTailUnicodeOffset; 119 const character_code = `${geulja}`.charCodeAt(0); 120 const relative_vowel_code = Math.floor(((character_code - hangulUnicodeRange.lower - padchim_offset) % 588) / 28); 121 const vowel_code = (relative_vowel_code + hangulVowelUnicodeOffset); 122 const vowel = String.fromCharCode(vowel_code); 123 return vowel; 124 } 125 export function hasHiddenPadchim(geulja) { 126 if (geulja instanceof Geulja) { 127 if (geulja.hidden_padchim) 128 return true; 129 } 130 return false; 131 } 132 export function getPadchim(geulja) { 133 if (geulja instanceof Geulja) { 134 if (geulja.original_padchim) 135 return geulja.original_padchim; 136 } 137 const character_code = `${geulja}`.charCodeAt(0); 138 const relative_tail_code = (character_code - hangulUnicodeRange.lower) % 28; 139 const tail_code = (relative_tail_code + hangulTailUnicodeOffset) - 1; 140 const padchim = String.fromCharCode(tail_code); 141 return tail_code === hangulEmptyConsonant ? null : padchim; 142 } 143 export function matchGeulja(geulja, lead = undefined, vowel = undefined, padchim = undefined) { 144 const matchesLead = lead !== undefined ? getLead(geulja) === lead : true; 145 const matchesVowel = vowel !== undefined ? getVowel(geulja) === vowel : true; 146 const matchesPadchim = padchim !== undefined ? (hasHiddenPadchim(geulja) || getPadchim(geulja)) === padchim : true; 147 return matchesLead && matchesVowel && matchesPadchim; 148 }