diff --git a/frontend/src/shared/utils/korean.spec.ts b/frontend/src/shared/utils/korean.spec.ts index 582da42..3c5d443 100644 --- a/frontend/src/shared/utils/korean.spec.ts +++ b/frontend/src/shared/utils/korean.spec.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest' import { directionParticle, objectParticle, subjectParticle } from './korean' +// 규칙은 korean.ts 머리말 — 짝: 앱 `flutter/lib/core/utils/korean_text.dart` (한쪽을 고치면 둘 다) + describe('subjectParticle (주격 조사 이/가)', () => { it.each([ ['받은편지함', '이'], @@ -27,9 +29,10 @@ describe('directionParticle (방향 조사 으로/로)', () => { // ㄹ 받침 → 로 ['서울', '로'], ['메일', '로'], - // 한글로 끝나지 않으면 발음을 알 수 없다 + // 숫자는 읽는 소리로 — 2026 은 "육" + ['2026', '으로'], + // 라틴 글자로 끝나면 소리를 알 수 없다 ['Work', '(으)로'], - ['2026', '(으)로'], ['', '(으)로'], ])('%s → %s', (word, particle) => { expect(directionParticle(word)).toBe(particle) @@ -49,3 +52,80 @@ describe('objectParticle (목적격 조사 을/를 — "휴지통을 비웠습 expect(objectParticle(word)).toBe(particle) }) }) + +describe('한글 호환 자모 (U+3131~U+3163 — 초성으로 찾은 말)', () => { + // 자음은 이름(기역 · 리을 · 미음 …)이 모두 받침으로 끝난다 — ㄹ(리을)만 ㄹ 받침이라 "로". 쌍자음 · 겹자음도 받침 있음(ㄹ 받침이 아니다) + it.each([ + ['ㄱ', '기역', '이', '을', '으로'], + ['ㄹ', '리을', '이', '을', '로'], + ['ㅁ', '미음', '이', '을', '으로'], + ['ㅎ', '히읗', '이', '을', '으로'], + ['ㄲ', '쌍기역', '이', '을', '으로'], + ['ㄳ', '기역시옷', '이', '을', '으로'], + ['ㄺ', '리을기역', '이', '을', '으로'], + ['ㅏ', '아', '가', '를', '로'], + ['ㅣ', '이', '가', '를', '로'], + ])('%s(%s) → %s · %s · %s', (jamo, reading, subject, object, direction) => { + expect([subjectParticle(jamo), objectParticle(jamo), directionParticle(jamo)]).toEqual([subject, object, direction]) + // 읽는 소리에 붙인 조사와 같다 + expect([subjectParticle(reading), objectParticle(reading), directionParticle(reading)]).toEqual([ + subject, + object, + direction, + ]) + }) + + it('초성 검색어는 마지막 자모로 고른다 — 범위 밖의 옛 자모는 소리를 알 수 없는 꼴', () => { + expect(directionParticle('ㄱㅁ')).toBe('으로') + expect(directionParticle('박ㅈ')).toBe('으로') + expect(subjectParticle('김ㅇㄹ')).toBe('이') + expect(directionParticle('김ㅇㄹ')).toBe('로') + // ㆍ(아래아 U+318D) — 호환 자모지만 U+3163 밖 + expect(subjectParticle('ㆍ')).toBe('이(가)') + }) +}) + +describe('숫자 0~9 — 읽는 소리(영 · 일 · 이 · 삼 · 사 · 오 · 육 · 칠 · 팔 · 구)의 받침으로', () => { + it.each([ + ['0', '영', '이', '을', '으로'], + ['1', '일', '이', '을', '로'], + ['2', '이', '가', '를', '로'], + ['3', '삼', '이', '을', '으로'], + ['4', '사', '가', '를', '로'], + ['5', '오', '가', '를', '로'], + ['6', '육', '이', '을', '으로'], + ['7', '칠', '이', '을', '로'], + ['8', '팔', '이', '을', '로'], + ['9', '구', '가', '를', '로'], + ])('%s(%s) → %s · %s · %s', (digit, reading, subject, object, direction) => { + expect([subjectParticle(digit), objectParticle(digit), directionParticle(digit)]).toEqual([subject, object, direction]) + // 읽는 소리에 붙인 조사와 같다 + expect([subjectParticle(reading), objectParticle(reading), directionParticle(reading)]).toEqual([ + subject, + object, + direction, + ]) + }) + + it('여러 자리 수 · 글자 뒤의 숫자도 마지막 숫자로 본다', () => { + expect(subjectParticle('2026')).toBe('이') + expect(subjectParticle('프로젝트2')).toBe('가') + expect(objectParticle('3분기 보고서 v10')).toBe('을') + expect(directionParticle('회의실 7')).toBe('로') + }) +}) + +describe('끝의 공백 · 닫는 따옴표 · 괄호 (지금 규칙 그대로)', () => { + it('앞뒤 공백은 지우고 본다 — 자모 · 숫자도 같다', () => { + expect(directionParticle('스팸메일함 ')).toBe('으로') + expect(directionParticle('ㄹ ')).toBe('로') + expect(subjectParticle(' 7 ')).toBe('이') + }) + + it('닫는 따옴표 · 괄호는 건너뛰지 않는다 — 기호로 끝나면 소리를 알 수 없는 꼴 (부르는 쪽이 따옴표 안의 말을 넘긴다)', () => { + expect(subjectParticle('보고서’')).toBe('이(가)') + expect(subjectParticle('"서버 이전"')).toBe('이(가)') + expect(objectParticle('휴지통)')).toBe('을(를)') + expect(directionParticle('회의(2)')).toBe('(으)로') + }) +}) diff --git a/frontend/src/shared/utils/korean.ts b/frontend/src/shared/utils/korean.ts index 9a821a3..a7cca88 100644 --- a/frontend/src/shared/utils/korean.ts +++ b/frontend/src/shared/utils/korean.ts @@ -1,4 +1,17 @@ // 한국어 문구 보조 — 순수 함수만 둔다 +// +// 조사는 앞말의 마지막 글자를 읽는 소리의 받침으로 고른다. 앞뒤 공백은 지우고 보며, 닫는 따옴표 · 괄호 같은 기호는 건너뛰지 않는다 +// (기호로 끝나면 소리를 알 수 없는 것으로 본다 — 부르는 쪽이 따옴표 안의 말을 넘긴다). +// - 한글 음절: 그 글자의 받침 +// - 한글 호환 자모(U+3131~U+3163 — 초성으로 찾을 때 흔하다): 자음 ㄱ~ㅎ은 이름(기역 · 니은 · 디귿 · 리을 · 미음 …)이 모두 +// 받침으로 끝나 받침 있음이고, ㄹ(리을)만 ㄹ 받침이다. 쌍자음 · 겹자음(ㄲ · ㄳ · ㄺ …)도 받침 있음(ㄹ 받침이 아니다). 모음 ㅏ~ㅣ는 받침 없음 +// - 숫자 0~9: 읽는 소리(영 · 일 · 이 · 삼 · 사 · 오 · 육 · 칠 · 팔 · 구)의 받침 — 받침 있음 0 · 1 · 3 · 6 · 7 · 8(그 가운데 ㄹ 받침 1 · 7 · 8), +// 받침 없음 2 · 4 · 5 · 9. 여러 자리 수도 마지막 숫자로 본다("2026" → 육) +// - 그 밖(라틴 글자 · 기호 · 빈 글자)은 소리를 알 수 없어 두 꼴을 함께 쓴다 — "이(가)" · "을(를)" · "(으)로" +// 짝: flutter `lib/core/utils/korean_text.dart` — 규칙을 바꾸면 둘 다 + +/** 마지막 소리의 받침 — 없음 · ㄹ 받침 · 그 밖의 받침 */ +type FinalSound = 'none' | 'rieul' | 'other' const HANGUL_SYLLABLE_START = 0xac00 const HANGUL_SYLLABLE_END = 0xd7a3 @@ -7,43 +20,73 @@ const JONGSEONG_COUNT = 28 /** 받침 번호 — 0 은 받침 없음, 8 은 ㄹ */ const NO_JONGSEONG = 0 const JONGSEONG_RIEUL = 8 +/** 한글 호환 자모 — 자음 ㄱ(U+3131)~ㅎ(U+314E), 그 가운데 ㄹ(U+3139), 모음 ㅏ(U+314F)~ㅣ(U+3163) */ +const JAMO_CONSONANT_START = 0x3131 +const JAMO_CONSONANT_END = 0x314e +const JAMO_RIEUL = 0x3139 +const JAMO_VOWEL_START = 0x314f +const JAMO_VOWEL_END = 0x3163 +/** 숫자 0~9 */ +const DIGIT_ZERO = 0x30 +const DIGIT_NINE = 0x39 +/** 숫자 0~9 를 읽는 소리의 받침 — 영 · 일 · 이 · 삼 · 사 · 오 · 육 · 칠 · 팔 · 구 */ +const DIGIT_FINAL_SOUNDS: readonly FinalSound[] = [ + 'other', // 0 영 + 'rieul', // 1 일 + 'none', // 2 이 + 'other', // 3 삼 + 'none', // 4 사 + 'none', // 5 오 + 'other', // 6 육 + 'rieul', // 7 칠 + 'rieul', // 8 팔 + 'none', // 9 구 +] -/** 마지막 글자의 받침 번호 — 한글 음절로 끝나지 않으면(영문 · 숫자 · 빈 글자) null */ -function lastJongseong(word: string): number | null { +/** 마지막 글자를 읽는 소리의 받침 — 소리를 알 수 없으면(라틴 글자 · 기호 · 빈 글자) null */ +function finalSound(word: string): FinalSound | null { const last = Array.from(word.trim()).pop() - if (!last) return null + const code = last?.codePointAt(0) + if (code === undefined) return null - const code = last.charCodeAt(0) - if (code < HANGUL_SYLLABLE_START || code > HANGUL_SYLLABLE_END) return null - return (code - HANGUL_SYLLABLE_START) % JONGSEONG_COUNT + if (code >= HANGUL_SYLLABLE_START && code <= HANGUL_SYLLABLE_END) { + const jongseong = (code - HANGUL_SYLLABLE_START) % JONGSEONG_COUNT + if (jongseong === NO_JONGSEONG) return 'none' + return jongseong === JONGSEONG_RIEUL ? 'rieul' : 'other' + } + if (code >= JAMO_CONSONANT_START && code <= JAMO_CONSONANT_END) return code === JAMO_RIEUL ? 'rieul' : 'other' + if (code >= JAMO_VOWEL_START && code <= JAMO_VOWEL_END) return 'none' + if (code >= DIGIT_ZERO && code <= DIGIT_NINE) return DIGIT_FINAL_SOUNDS[code - DIGIT_ZERO] ?? null + return null } /** - * 주격 조사 "이/가" — 마지막 글자에 받침이 있으면 "이", 없으면 "가". - * 한글로 끝나지 않는 이름(영문·숫자)은 발음을 알 수 없어 "이(가)" 를 쓴다. + * 주격 조사 "이/가" — 마지막 소리에 받침이 있으면 "이", 없으면 "가" (휴지통이 · 프로젝트가 · ㄱ이 · 2가). + * 소리를 알 수 없는 이름(라틴 글자 · 기호로 끝남)은 "이(가)" 를 쓴다. */ export function subjectParticle(word: string): '이' | '가' | '이(가)' { - const jongseong = lastJongseong(word) - if (jongseong === null) return '이(가)' - return jongseong === NO_JONGSEONG ? '가' : '이' + const sound = finalSound(word) + if (sound === null) return '이(가)' + return sound === 'none' ? '가' : '이' } /** - * 목적격 조사 "을/를" — 받침이 있으면 "을", 없으면 "를" (휴지통을 · 스팸메일함을 · 보관함을 · 메일 서버를). - * 한글로 끝나지 않는 이름(영문·숫자)은 발음을 알 수 없어 "을(를)" 을 쓴다. + * 목적격 조사 "을/를" — 받침이 있으면 "을", 없으면 "를" (휴지통을 · 스팸메일함을 · 보관함을 · 메일 서버를 · 3을 · ㅏ를). + * 소리를 알 수 없는 이름(라틴 글자 · 기호로 끝남)은 "을(를)" 을 쓴다. */ export function objectParticle(word: string): '을' | '를' | '을(를)' { - const jongseong = lastJongseong(word) - if (jongseong === null) return '을(를)' - return jongseong === NO_JONGSEONG ? '를' : '을' + const sound = finalSound(word) + if (sound === null) return '을(를)' + return sound === 'none' ? '를' : '을' } /** - * 방향 조사 "으로/로" — 받침이 있으면 "으로", 받침이 없거나 ㄹ 받침이면 "로" (보낸편지함으로 · 휴지통으로 · 보관함으로 · 서울로). - * 한글로 끝나지 않는 이름(영문·숫자)은 발음을 알 수 없어 "(으)로" 를 쓴다. + * 방향 조사 "으로/로" — 받침이 있으면 "으로", 받침이 없거나 ㄹ 받침이면 "로" + * (보낸편지함으로 · 휴지통으로 · 보관함으로 · 서울로 · ㄱ으로 · ㄹ로 · 6으로 · 7로). + * 소리를 알 수 없는 이름(라틴 글자 · 기호로 끝남)은 "(으)로" 를 쓴다. */ export function directionParticle(word: string): '으로' | '로' | '(으)로' { - const jongseong = lastJongseong(word) - if (jongseong === null) return '(으)로' - return jongseong === NO_JONGSEONG || jongseong === JONGSEONG_RIEUL ? '로' : '으로' + const sound = finalSound(word) + if (sound === null) return '(으)로' + return sound === 'other' ? '으로' : '로' }