UNPKG

koroman

Version:

KOROMAN: Korean Romanizer with pronunciation rules based on 국립국어원 표기법

557 lines (481 loc) 26.3 kB
// @name: koroman.core.js // @project: Koroman // @author: Donghe Youn (Daissue) // @date: 2025-04-02 // @description: This module provides core functionality for romanizing Korean Hangul text. // It includes functions to decompose Hangul syllables into their constituent jamo (initial, medial, final), // apply Korean pronunciation rules, and convert them into Latin (Romanized) script. // This module is used by both CommonJS and ESModule entry points and is not intended to be used directly. // @license: MIT License // @version: 1.0.16 // @dependencies: None // @usage: Import this from koroman.mjs or koroman.cjs to access the romanize() function. // 자모 분해 및 조합 + 로마자 변환 (초성/중성/종성 실제 유니코드 문자 사용 버전) // 초성: 19자 (U+1100~U+1112) const CHO = [ "ᄀ", "ᄁ", "ᄂ", "ᄃ", "ᄄ", "ᄅ", "ᄆ", "ᄇ", "ᄈ", "ᄉ", "ᄊ", "ᄋ", "ᄌ", "ᄍ", "ᄎ", "ᄏ", "ᄐ", "ᄑ", "ᄒ" ]; // 중성: 21자 (U+1161~U+1175) const JUNG = [ "ᅡ", "ᅢ", "ᅣ", "ᅤ", "ᅥ", "ᅦ", "ᅧ", "ᅨ", "ᅩ", "ᅪ", "ᅫ", "ᅬ", "ᅭ", "ᅮ", "ᅯ", "ᅰ", "ᅱ", "ᅲ", "ᅳ", "ᅴ", "ᅵ" ]; // 종성: 28자 (첫 번째는 없음, 나머지 U+11A8~U+11C2) const JONG = [ "", "ᆨ", "ᆩ", "ᆪ", "ᆫ", "ᆬ", "ᆭ", "ᆮ", "ᆯ", "ᆰ", "ᆱ", "ᆲ", "ᆳ", "ᆴ", "ᆵ", "ᆶ", "ᆷ", "ᆸ", "ᆹ", "ᆺ", "ᆻ", "ᆼ", "ᆽ", "ᆾ", "ᆿ", "ᇀ", "ᇁ", "ᇂ" ]; const ZWSP = '\u200A'; // Hair Space (조합 방지용) // const ZWSP = '\u200B'; // zero-width space (조합 방지용) // casingOption 별칭/숫자 매핑 (1.0.14~) — 풀네임/약어/숫자 모두 허용, case-insensitive const CASING_ALIASES = { 'lowercase': 'lowercase', 'lower': 'lowercase', 'l': 'lowercase', 'lc': 'lowercase', '0': 'lowercase', 'uppercase': 'uppercase', 'upper': 'uppercase', 'u': 'uppercase', 'uc': 'uppercase', '1': 'uppercase', 'capitalize-line': 'capitalize-line', 'cap-line': 'capitalize-line', 'cline': 'capitalize-line', 'cl': 'capitalize-line', '2': 'capitalize-line', 'capitalize-word': 'capitalize-word', 'cap-word': 'capitalize-word', 'cword': 'capitalize-word', 'cw': 'capitalize-word', '3': 'capitalize-word' }; function normalizeCasingOption(opt) { if (opt === null || opt === undefined) return 'lowercase'; const key = String(opt).toLowerCase(); return CASING_ALIASES[key] ?? 'lowercase'; } function formatRoman(str, casingOption = "lowercase") { switch (normalizeCasingOption(casingOption)) { case "uppercase": return str.toUpperCase(); case "capitalize-line": return str.split('\n').map(line => line.length > 0 ? line.charAt(0).toUpperCase() + line.slice(1) : '').join('\n'); case "capitalize-word": return str.split('\n').map(line => line.split(' ').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')).join('\n'); default: return str.toLowerCase(); } } function splitHangulToJamos(str) { const result = []; let jamoString = ''; let plainJamoString = ''; for (let char of str) { const code = char.charCodeAt(0); if (code < 0xAC00 || code > 0xD7A3) { result.push({ char, type: 'non-hangul' }); jamoString += char; plainJamoString += char; continue; } const syllableIndex = code - 0xAC00; const cho = Math.floor(syllableIndex / (21 * 28)); const jung = Math.floor((syllableIndex % (21 * 28)) / 28); const jong = syllableIndex % 28; const jamo = { 초성: CHO[cho], 중성: JUNG[jung], 종성: JONG[jong] || null }; result.push(jamo); jamoString += jamo.초성 + jamo.중성 + (jamo.종성 || ''); plainJamoString += jamo.초성 + ZWSP + jamo.중성; if (jamo.종성) plainJamoString += ZWSP + jamo.종성; } return { jamoArray: result, jamoString, plainJamoString }; } function composeJamos(jamoArray) { let result = ''; for (let jamo of jamoArray) { if (jamo.type === 'non-hangul') { result += jamo.char; continue; } const choIndex = CHO.indexOf(jamo.초성); const jungIndex = JUNG.indexOf(jamo.중성); const jongIndex = jamo.종성 ? JONG.indexOf(jamo.종성) : 0; if (choIndex < 0 || jungIndex < 0 || jongIndex < 0) { result += '?'; continue; } const code = 0xAC00 + (choIndex * 21 + jungIndex) * 28 + jongIndex; result += String.fromCharCode(code); } return result; } // 표준발음법 29항 ㄴ첨가 — 형태소 분석 없이 적용 가능한 부분 집합. // 뒤 음절이 합성어에서 거의 고정인 트리거(잎)일 때만 적용. (많이·같이 등 단일어 오탐 방지) const N_INSERTION_TRIGGERS = new Set(['잎']); function hangulSyllableIndices(ch) { const n = ch.charCodeAt(0) - 0xAC00; return { cho: Math.floor(n / (21 * 28)), jung: Math.floor((n % (21 * 28)) / 28), jong: n % 28 }; } function composeHangulSyllable(cho, jung, jong = 0) { return String.fromCharCode(0xAC00 + (cho * 21 + jung) * 28 + jong); } /** 합성어 ㄴ첨가(29항) — 받침 + 잎 → 뒤 음절 초성 ㄴ 첨가 (꽃잎→[꼰닙] 등) */ function applyNInsertionTriggers(text) { return text.replace(/[\uAC00-\uD7A3]+/g, (word) => { const chars = [...word]; if (chars.length < 2) return word; const last = chars[chars.length - 1]; if (!N_INSERTION_TRIGGERS.has(last)) return word; const prev = chars[chars.length - 2]; const prevParts = hangulSyllableIndices(prev); if (prevParts.jong === 0) return word; const lastParts = hangulSyllableIndices(last); if (lastParts.cho !== 11) return word; // 잎 = ᄋ+ᅵ+… const nip = composeHangulSyllable(2, lastParts.jung, lastParts.jong); // ᄂ+중성+종성 return chars.slice(0, -1).join('') + nip; }); } function applyPronunciationRules(jamoStr) { const replaceArr = [ // ============================== // 1. 무효화 처리 // ============================== { p: /\u11a7/g, r: "" }, // 'ᆧ'(U+11A7) → 제거 (사용되지 않는 종성) // ============================== // 2. 비음화 (ㄴ, ㅁ, ㅇ) // ============================== { p: /[\u11b8\u11c1\u11b9\u11b2\u11b5](?=[\u1102\u1106])/g, r: "ᆷ" }, // 종성 'ᆸ(ㅂ)' 'ᇁ(ㅍ)' 'ᆹ(ㅂㅅ)' 'ᆲ(ㄹㅂ)' 'ᆵ(ㄹㅍ)' + 다음 초성 'ᄂ(ㄴ)' or 'ᄆ(ㅁ)' → 'ᆷ' { p: /[\u11ae\u11c0\u11bd\u11be\u11ba\u11bb\u11c2](?=[\u1102\u1106])/g, r: "ᆫ" }, // 종성 'ᆮ(ㄷ)' 'ᇀ(ㅌ)' 'ᆽ(ㅈ)' 'ᆾ(ㅊ)' 'ᆺ(ㅅ)' 'ᆻ(ㅆ)' 'ᇂ(ㅎ)' + 다음 초성 'ᄂ(ㄴ)' or 'ᄆ(ㅁ)' → 'ᆫ' { p: /[\u11a8\u11a9\u11bf\u11aa\u11b0](?=[\u1102\u1106])/g, r: "ᆼ" }, // 종성 'ᆨ(ㄱ)' 'ᆩ(ㄲ)' 'ᆿ(ㅋ)' 'ᆪ(ㄱㅅ)' 'ᆰ(ㄹㄱ)' + 다음 초성 'ᄂ'/'ᄆ' → 'ᆼ' // ============================== // 3. 연음/연철 // ============================== { p: /\u11a8\u110b(?=[\u1163\u1164\u1167\u1168\u116d\u1172])/g, r: "ᆼᄂ" }, // 'ᆨ' + 'ᄋ' + 중성 'ㅑㅒㅕㅖㅛㅠ' → 'ᆼᄂ' (연음화) { p: /\u11af\u110b(?=[\u1163\u1164\u1167\u1168\u116d\u1172])/g, r: "ᆯᄅ" }, // 'ᆯ' + 'ᄋ' + 중성 위와 같음 → 'ᆯᄅ' { p: /[\u11a8\u11bc]\u1105/g, r: "ᆼᄂ" }, // 'ᆨ(ㄱ)', 'ᆼ(ㅇ)' + 'ᄅ(ㄹ)' → 'ᆼᄂ' { p: /\u11ab\u1105(?=\u1169)/g, r: "ᆫᄂ" }, // 'ᆫ(ㄴ)' + 'ᄅ' + 중성 'ㅗ' → 'ᆫᄂ' { p: /\u11af\u1102|\u11ab\u1105/g, r: "ᆯᄅ" }, // 'ᆯ(ㄹ)' + 'ᄂ(ㄴ)', 'ᆫ(ㄴ)' + 'ᄅ(ㄹ)' → 'ᆯᄅ' { p: /[\u11b7\u11b8]\u1105/g, r: "ᆷᄂ" }, // 'ᆷ(ㅁ)', 'ᆸ(ㅂ)' + 'ᄅ' → 'ᆷᄂ' { p: /\u11b0\u1105/g, r: "ᆨᄅ" }, // 'ᆰ(ㄹㄱ)' + 'ᄅ' → 'ᆨᄅ' // ============================== // 4. 격음화 / 자음군 분해 // ============================== { p: /\u11a8\u110f/g, r: "ᆨ-ᄏ" }, // 'ᆨ' + 'ᄏ' → 'ᆨ-ᄏ' { p: /\u11b8\u1111/g, r: "ᆸ-ᄑ" }, // 'ᆸ' + 'ᄑ' → 'ᆸ-ᄑ' { p: /\u11ae\u1110/g, r: "ᆮ-ᄐ" }, // 'ᆮ' + 'ᄐ' → 'ᆮ-ᄐ' // ============================== // 5. 복합 종성(자음군) + 'ᄋ' 모음 연음 처리 (표준발음법 14항) // 뒤 자음만 다음 음절 초성으로 옮겨감. 분해보다 먼저 적용해야 // 이중종성 dedup 규칙으로 자음이 잘못 사라지지 않는다. // 예: 닭이[달기] → ᆰᄋ → ᆯᄀ → "dalgi" // 잃어[이러] → ᆶᄋ → ᆯᄋ → 후속 연음으로 ᄅ → "ireo" // (된소리는 표기에 반영하지 않음 — 넋이[넉씨] → "neoksi") // ============================== { p: /\u11aa\u110b/g, r: "\u11a8\u1109" }, // ᆪ + 'ᄋ' → ᆨ + 'ᄉ' (넋이 → 넉시) { p: /\u11ac\u110b/g, r: "\u11ab\u110c" }, // ᆬ + 'ᄋ' → ᆫ + 'ᄌ' (앉아 → 안자) { p: /\u11ad\u110b/g, r: "\u11ab\u110b" }, // ᆭ + 'ᄋ' → ᆫ + 'ᄋ' (ㅎ만 탈락; 일관성) { p: /\u11b0\u110b/g, r: "\u11af\u1100" }, // ᆰ + 'ᄋ' → ᆯ + 'ᄀ' (닭이 → 달기) { p: /\u11b1\u110b/g, r: "\u11af\u1106" }, // ᆱ + 'ᄋ' → ᆯ + 'ᄆ' (삶이 → 살미) { p: /\u11b2\u110b/g, r: "\u11af\u1107" }, // ᆲ + 'ᄋ' → ᆯ + 'ᄇ' (밟아 → 발바) { p: /\u11b3\u110b/g, r: "\u11af\u1109" }, // ᆳ + 'ᄋ' → ᆯ + 'ᄉ' (곬이 → 골시) { p: /\u11b4\u110b/g, r: "\u11af\u1110" }, // ᆴ + 'ᄋ' → ᆯ + 'ᄐ' (핥아 → 할타) { p: /\u11b5\u110b/g, r: "\u11af\u1111" }, // ᆵ + 'ᄋ' → ᆯ + 'ᄑ' (읊어 → 을퍼) { p: /\u11b6\u110b/g, r: "\u11af\u110b" }, // ᆶ + 'ᄋ' → ᆯ + 'ᄋ' (잃어 → 이러; ㅎ만 탈락) { p: /\u11b9\u110b/g, r: "\u11b8\u1109" }, // ᆹ + 'ᄋ' → ᆸ + 'ᄉ' (값이 → 갑시) // ============================== // 5b. 밟- 어간 예외 (표준발음법 제10항 예외) // 'ㄼ' 받침은 일반적으로 [ㄹ]로 발음되지만, '밟-' 어간은 자음 앞에서 [ㅂ]로 발음. // 예: 밟다[밥따] → bapda, 밟지[밥찌] → bapji, 밟고[밥꼬] → bapgo, 밟는[밤는] → bamneun (비음화 우선) // 위 5번 규칙으로 'ᆲ+ᄋ' (밟아) 케이스는 이미 처리됨. // ============================== { p: /\u1107\u1161\u11b2(?=[\u1100-\u110A\u110C-\u1112])/g, r: "\u1107\u1161\u11b8" }, // ============================== // 6. 복합 종성 분해 (자음군 단순화) // 분해 순서: 받침 위치에서 [표준 발음법상 남는 자음]을 첫 자모로 둠. // 뒤이은 이중종성 dedup 규칙으로 두 번째 자모가 떨어져 나가도록 함. // (예: 흙[흑] → ᆨᆯ → ᆨ, 삶[삼] → ᆷᆯ → ᆷ, 읊[읍] → ᇁᆯ → ᇁ) // ============================== { p: /\u11aa/g, r: "ᆨᆺ" }, // 'ᆪ(ㄱㅅ)' → [ㄱ] 우세 { p: /\u11ac/g, r: "ᆫᆽ" }, // 'ᆬ(ㄴㅈ)' → [ㄴ] 우세 { p: /\u11ad/g, r: "ᆫᇂ" }, // 'ᆭ(ㄴㅎ)' → [ㄴ] 우세 { p: /\u11b0/g, r: "ᆨᆯ" }, // 'ᆰ(ㄹㄱ)' → [ㄱ] 우세 (예: 흙→heuk) { p: /\u11b1/g, r: "ᆷᆯ" }, // 'ᆱ(ㄹㅁ)' → [ㅁ] 우세 (예: 삶→sam) { p: /\u11b2/g, r: "ᆯᆸ" }, // 'ᆲ(ㄹㅂ)' → [ㄹ] 우세 (밟- 예외 [ㅂ]는 형태소 정보 필요) { p: /\u11b3/g, r: "ᆯᆺ" }, // 'ᆳ(ㄹㅅ)' → [ㄹ] 우세 { p: /\u11b4/g, r: "ᆯᇀ" }, // 'ᆴ(ㄹㅌ)' → [ㄹ] 우세 { p: /\u11b5/g, r: "ᇁᆯ" }, // 'ᆵ(ㄹㅍ)' → [ㅂ] 우세 (예: 읊→eup) { p: /\u11b6/g, r: "ᆯᇂ" }, // 'ᆶ(ㄹㅎ)' → [ㄹ] 우세 (모음 앞은 위 5단계에서 선처리) { p: /\u11b9/g, r: "ᆸᆺ" }, // 'ᆹ(ㅂㅅ)' → [ㅂ] 우세 // ============================== // 7. 구개음화 (ㄷ/ㅌ + ᄋㅣ → 지/치) + ㄷ+ㅎ+ㅣ → 치 (격음화+구개음화 통합) // ============================== { p: /\u11ae\u110b\u1175/g, r: "지" }, // 'ᆮ' + 'ᄋ' + 'ᅵ' → '지' { p: /\u11c0\u110b\u1175/g, r: "치" }, // 'ᇀ' + 'ᄋ' + 'ᅵ' → '치' // ============================== // 7b. ㄷ + ㅎ + ㅣ → 치 (받침 ㄷ + 초성 ㅎ + 중성 ㅣ: 격음화 결과인 ㅌ가 다시 구개음화) // 예: 굳히다 → 구치다, 닫히다 → 다치다 { p: /\u11ae\u1112\u1175/g, r: "\u110e\u1175" }, // ============================== // 8. 연음 / 이음자 제거 (받침 + ᄋ) // ============================== { p: /\u11a8\u110b/g, r: "ᄀ" }, // 'ᆨ' + 'ᄋ' → 'ᄀ' { p: /\u11a9\u110b/g, r: "ᄁ" }, // 'ᆩ' + 'ᄋ' → 'ᄁ' { p: /\u11ae\u110b/g, r: "ᄃ" }, // 'ᆮ' + 'ᄋ' → 'ᄃ' { p: /\u11af\u110b/g, r: "ᄅ" }, // 'ᆯ' + 'ᄋ' → 'ᄅ' { p: /\u11b8\u110b/g, r: "ᄇ" }, // 'ᆸ' + 'ᄋ' → 'ᄇ' { p: /\u11ba\u110b/g, r: "ᄉ" }, // 'ᆺ' + 'ᄋ' → 'ᄉ' { p: /\u11bb\u110b/g, r: "ᄊ" }, // 'ᆻ' + 'ᄋ' → 'ᄊ' { p: /\u11bd\u110b/g, r: "ᄌ" }, // 'ᆽ' + 'ᄋ' → 'ᄌ' { p: /\u11be\u110b/g, r: "ᄎ" }, // 'ᆾ' + 'ᄋ' → 'ᄎ' { p: /\u11c2\u110b/g, r: "" }, // 'ᇂ' + 'ᄋ' → 제거 // ============================== // 9. 거센소리되기 (Aspiration) — ROMANIZE_RULE 4 기본 정책 // 체언 예외(묵호→Mukho, 집현전→Jiphyeonjeon)는 형태소 분석 없이는 분리 불가하므로 // 일관되게 단일 격음(k/t/p/ch)으로 처리. 표준 발음 결과 기준. // ============================== { p: /\u11c2\u1100|\u11a8\u1112/g, r: "ᄏ" }, // 'ᇂ'+'ᄀ' or 'ᆨ'+'ᄒ' → 'ᄏ' (좋고→joko) { p: /\u11c2\u1103|\u11ae\u1112/g, r: "ᄐ" }, // 'ᇂ'+'ᄃ' or 'ᆮ'+'ᄒ' → 'ᄐ' (놓다→nota) { p: /\u11c2\u110c|\u11bd\u1112/g, r: "ᄎ" }, // 'ᇂ'+'ᄌ' or 'ᆽ'+'ᄒ' → 'ᄎ' (낳지→nachi, 맞히다→machida) { p: /\u11c2\u1107/g, r: "ᄇ" }, // 'ᇂ'+'ᄇ' → 'ᄇ' { p: /\u11b8\u1112/g, r: "ᄑ" }, // 'ᆸ'+'ᄒ' → 'ᄑ' (잡혀→japyeo) // ============================== // 10. 특수 처리 및 최종 정리 // ============================== { p: /\u11af\u1105/g, r: "ll" }, // 'ᆯ' + 'ᄅ' → ll { p: /\u11c2(?!\s|$)/g, r: "" }, // 'ᇂ' (종성) 단독 → 제거 { p: /([\u11a8-\u11c2])([\u11a8-\u11c2])/g, r: "$1" } // 이중 종성 제거 (자음군 단순화 마무리) ]; for (const { p, r } of replaceArr) { jamoStr = jamoStr.replace(p, r); } return jamoStr; } // 자모 → 로마자 매핑 테이블 // // ROMAN_MAP_STD: 표준 발음 기반 매핑 (ROMANIZE_RULE 3·4). usePronunciationRules=true 일 때 사용. // - 받침 ㄷ/ㅈ/ㅊ/ㅌ/ㅎ → 'ㄷ' 대표음(t), 받침 ㅎ도 단독으로 남으면 't'. // // ROMAN_MAP_LITERAL: 직역/학술적 표기에 가까운 매핑 (ROMANIZE_RULE 5 일부 반영). // usePronunciationRules=false 일 때 사용. 음운변화 가정 없이 글자를 그대로 옮김. // - 종성 ㄷ → 'd' (예: 굳이 → gudi), 종성 ㅎ → 'h' (예: 좋다 → johda) // - 기존 1.0.14 동작과 호환. const ROMAN_MAP_STD = { 'ᄀ': 'g', 'ᄁ': 'kk', 'ᄂ': 'n', 'ᄃ': 'd', 'ᄄ': 'tt', 'ᄅ': 'r', 'ᄆ': 'm', 'ᄇ': 'b', 'ᄈ': 'pp', 'ᄉ': 's', 'ᄊ': 'ss', 'ᄋ': '', 'ᄌ': 'j', 'ᄍ': 'jj', 'ᄎ': 'ch', 'ᄏ': 'k', 'ᄐ': 't', 'ᄑ': 'p', 'ᄒ': 'h', 'ᅡ': 'a', 'ᅢ': 'ae', 'ᅣ': 'ya', 'ᅤ': 'yae', 'ᅥ': 'eo', 'ᅦ': 'e', 'ᅧ': 'yeo', 'ᅨ': 'ye', 'ᅩ': 'o', 'ᅪ': 'wa', 'ᅫ': 'wae', 'ᅬ': 'oe', 'ᅭ': 'yo', 'ᅮ': 'u', 'ᅯ': 'wo', 'ᅰ': 'we', 'ᅱ': 'wi', 'ᅲ': 'yu', 'ᅳ': 'eu', 'ᅴ': 'ui', 'ᅵ': 'i', 'ᆨ': 'k', 'ᆩ': 'k', 'ᆪ': 'k', 'ᆫ': 'n', 'ᆬ': 'n', 'ᆭ': 'n', 'ᆮ': 't', 'ᆯ': 'l', 'ᆰ': 'k', 'ᆱ': 'm', 'ᆲ': 'p', 'ᆳ': 't', 'ᆴ': 't', 'ᆵ': 'p', 'ᆶ': 'l', 'ᆷ': 'm', 'ᆸ': 'p', 'ᆹ': 'p', 'ᆺ': 't', 'ᆻ': 't', 'ᆼ': 'ng', 'ᆽ': 't', 'ᆾ': 't', 'ᆿ': 'k', 'ᇀ': 't', 'ᇁ': 'p', 'ᇂ': 't' }; const ROMAN_MAP_LITERAL = { ...ROMAN_MAP_STD, 'ᆮ': 'd', 'ᇂ': 'h' }; function applyRomanMapping(jamoStr, map = ROMAN_MAP_STD) { return [...jamoStr].map(ch => map[ch] ?? ch).join(''); } // ---- 음절 재합성 + 붙임표(useHyphen) 처리 ------------------------------------ // 음운규칙이 적용된 자모열을 다시 (초/중/종) 음절 단위로 묶어 // 음절 경계 모호 케이스에 '-'를 삽입한다. // // 모호 케이스 (표준 로마자 표기법: "발음상 혼동의 우려가 있을 때 붙임표"): // 1. 앞 음절 종성 ᆼ(ng) + 뒤 음절 초성 ᄋ(모음시작) 예) 중앙 → jung-ang // 2. 앞 음절 종성 ᆫ(n) + 뒤 음절 초성 ᄀ(g) 예) 반구대 → ban-gudae // 3. 앞 음절 종성 없음 + 뒤 음절 초성 ᄋ(모음시작) 예) 해운대 → hae-undae, 세운 → se-un // --------------------------------------------------------------------------- const CHO_RANGE = [0x1100, 0x1112]; const JUNG_RANGE = [0x1161, 0x1175]; const JONG_RANGE = [0x11A8, 0x11C2]; function inRange(code, [lo, hi]) { return code >= lo && code <= hi; } // 자모열을 [{type:'syllable', cho, jung, jong}, {type:'other', char}] 시퀀스로 그룹화. // 종성 없는 음절은 jong === ''. 초성/중성 매칭 실패한 단독 자모/문자는 'other'. function regroupJamosToSyllables(jamoStr) { const groups = []; let i = 0; while (i < jamoStr.length) { const ch = jamoStr[i]; const code = ch.charCodeAt(0); if (inRange(code, CHO_RANGE) && i + 1 < jamoStr.length && inRange(jamoStr.charCodeAt(i + 1), JUNG_RANGE)) { const cho = ch; const jung = jamoStr[i + 1]; let jong = ''; let step = 2; if (i + 2 < jamoStr.length && inRange(jamoStr.charCodeAt(i + 2), JONG_RANGE)) { jong = jamoStr[i + 2]; step = 3; } groups.push({ type: 'syllable', cho, jung, jong }); i += step; continue; } groups.push({ type: 'other', char: ch }); i++; } return groups; } function romanizeGroup(g, map) { if (g.type === 'syllable') { return (map[g.cho] ?? '') + (map[g.jung] ?? '') + (g.jong ? (map[g.jong] ?? '') : ''); } return map[g.char] ?? g.char; } function needsHyphen(prev, cur) { if (!prev || !cur || prev.type !== 'syllable' || cur.type !== 'syllable') return false; const prevJong = prev.jong; const curCho = cur.cho; // 규칙1: ᆼ + ᄋ (모음 시작) if (prevJong === '\u11BC' && curCho === '\u110B') return true; // 규칙2: ᆫ + ᄀ if (prevJong === '\u11AB' && curCho === '\u1100') return true; // 규칙3: 받침 없음 + ᄋ (모음 시작) if (!prevJong && curCho === '\u110B') return true; return false; } function applyRomanMappingWithHyphen(jamoStr, map = ROMAN_MAP_STD) { const groups = regroupJamosToSyllables(jamoStr); let out = ''; for (let i = 0; i < groups.length; i++) { const cur = groups[i]; if (i > 0 && needsHyphen(groups[i - 1], cur)) { // 이미 끝이 '-'(예: 'ᆨ-ᄏ' 규칙에 의해 삽입)이면 중복 삽입 방지 if (!out.endsWith('-')) out += '-'; } out += romanizeGroup(cur, map); } return out; } // ---- 사용자 사전(customDictionary) 처리 ------------------------------------- // 사용자가 사전을 넘기면 해당 한국어 단어를 사용자가 지정한 로마자 표기로 최우선 변환한다. // 사전 값은 casing 옵션의 영향을 받지 않으며(사용자 의도 보존), 음운규칙/매핑도 거치지 않는다. // // 동작 방식: // 1) 한국어 텍스트에서 사전 키를 길이 내림차순으로 찾아 placeholder(\u0001N\u0001)로 치환 // 2) 평소대로 romanize 파이프라인 수행 (placeholder는 비한글이라 그대로 보존됨) // 3) casing 후처리까지 끝낸 결과에서 placeholder를 사전 값으로 복원 // --------------------------------------------------------------------------- const DICT_MARK = '\u0001'; const DICT_PATTERN = /\u0001(\d+)\u0001/g; // 모듈 내부 영구 사전 저장소. set/add/remove/clear 함수로 관리하고, // romanize 호출 시 useCustomDictionary 옵션(기본 true)에 따라 자동 사용됨. const _customDictionaryStore = {}; function _isPlainObject(v) { return v !== null && typeof v === 'object' && !Array.isArray(v); } function _resetStore() { for (const k of Object.keys(_customDictionaryStore)) delete _customDictionaryStore[k]; } function _mergeIntoStore(dict) { if (!_isPlainObject(dict)) return; for (const [k, v] of Object.entries(dict)) { if (k && typeof k === 'string') _customDictionaryStore[k] = String(v); } } /** 내부 사전을 통째로 교체한다. 빈 객체/falsy 값을 넘기면 전체 비움. */ function setCustomDictionary(dict) { _resetStore(); _mergeIntoStore(dict); } /** * 내부 사전에 항목을 추가/병합한다. * - addCustomDictionary({ "김철수": "Kim Chul-soo" }) * - addCustomDictionary("김철수", "Kim Chul-soo") */ function addCustomDictionary(dictOrKey, value) { if (typeof dictOrKey === 'string') { if (dictOrKey) _customDictionaryStore[dictOrKey] = String(value); } else if (_isPlainObject(dictOrKey)) { _mergeIntoStore(dictOrKey); } } /** 내부 사전에서 특정 키를 제거한다. 제거되었으면 true. */ function removeCustomDictionaryEntry(key) { if (typeof key === 'string' && key in _customDictionaryStore) { delete _customDictionaryStore[key]; return true; } return false; } /** 내부 사전을 모두 비운다. */ function clearCustomDictionary() { _resetStore(); } /** 현재 내부 사전의 스냅샷(얕은 복사)을 반환한다 (리스팅). */ function getCustomDictionary() { return { ..._customDictionaryStore }; } function applyCustomDictionary(text, dict) { if (!dict) return { text, values: [] }; const keys = Object.keys(dict).filter(k => k && k.length > 0).sort((a, b) => b.length - a.length); if (keys.length === 0) return { text, values: [] }; const values = []; for (const key of keys) { const value = dict[key]; let pos = 0; while (true) { const idx = text.indexOf(key, pos); if (idx === -1) break; const placeholder = `${DICT_MARK}${values.length}${DICT_MARK}`; values.push(value); text = text.slice(0, idx) + placeholder + text.slice(idx + key.length); pos = idx + placeholder.length; } } return { text, values }; } function restoreCustomDictionary(text, values) { if (!values || values.length === 0) return text; return text.replace(DICT_PATTERN, (_, n) => { const i = parseInt(n, 10); return (i >= 0 && i < values.length) ? values[i] : ''; }); } // 내부 사전 + 옵션 사전을 병합해 실효 사전을 만든다. // - useCustomDictionary=false: 내부 사전 무시 // - customDictionary 옵션: 항상 병합되며 동일 키는 옵션 사전이 우선 function _resolveEffectiveDictionary(useCustomDictionary, customDictionary) { const internal = useCustomDictionary ? _customDictionaryStore : null; if (!internal && !_isPlainObject(customDictionary)) return null; const out = internal ? { ...internal } : {}; if (_isPlainObject(customDictionary)) Object.assign(out, customDictionary); return out; } function romanize(str, { usePronunciationRules = true, casingOption = "lowercase", useHyphen = false, useCustomDictionary = true, customDictionary } = {}) { const effectiveDict = _resolveEffectiveDictionary(useCustomDictionary, customDictionary); const { text: prepped, values } = applyCustomDictionary(str, effectiveDict); const textForJamo = usePronunciationRules ? applyNInsertionTriggers(prepped) : prepped; const { jamoString } = splitHangulToJamos(textForJamo); const replaced = usePronunciationRules ? applyPronunciationRules(jamoString) : jamoString; const map = usePronunciationRules ? ROMAN_MAP_STD : ROMAN_MAP_LITERAL; const romanized = useHyphen ? applyRomanMappingWithHyphen(replaced, map) : applyRomanMapping(replaced, map); const cased = formatRoman(romanized, casingOption); return restoreCustomDictionary(cased, values); } export { romanize, splitHangulToJamos, composeJamos, formatRoman, applyNInsertionTriggers, applyPronunciationRules, applyRomanMapping, applyRomanMappingWithHyphen, applyCustomDictionary, restoreCustomDictionary, setCustomDictionary, addCustomDictionary, removeCustomDictionaryEntry, clearCustomDictionary, getCustomDictionary, normalizeCasingOption };