From 3278e1abc1843c4dac0d8adbfcd54ca68a1da9c8 Mon Sep 17 00:00:00 2001 From: ttipo Date: Sat, 7 Nov 2026 14:42:43 +0900 Subject: [PATCH] =?UTF-8?q?feat(flutter):=20=EC=A1=B0=EC=82=AC=20=EB=8F=84?= =?UTF-8?q?=EC=9A=B0=EB=AF=B8=EA=B0=80=20=ED=98=B8=ED=99=98=20=EC=9E=90?= =?UTF-8?q?=EB=AA=A8=20=C2=B7=20=EC=88=AB=EC=9E=90=EC=9D=98=20=EB=B0=9B?= =?UTF-8?q?=EC=B9=A8=EB=8F=84=20=EA=B3=A0=EB=A5=B8=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 웹 조사 도우미(frontend shared/utils/korean.ts)와 짝으로 맞춘다. 초성 검색어(‘ㄱㅁ’)나 숫자로 끝나는 말이 "(으)로" · "이(가)" 꼴로 나오지 않게, 호환 자모 자음은 받침 있음 (ㄹ 은 "로"), 모음은 받침 없음, 숫자는 읽는 소리(영 · 일 · 이 …)로 고른다. 라틴 글자 · 기호로 끝나면 지금처럼 괄호 꼴이다. 알려진 한계: 라틴 글자 뒤의 숫자도 한국어 소리로 센다(‘녹음.mp3’은). Co-Authored-By: Claude Opus 5.5 --- flutter/lib/core/utils/korean_text.dart | 102 +++++++++++++----- .../mail/presentation/mail_texts.dart | 2 +- .../mail/compose_attachments_test.dart | 2 +- .../test/features/mail/mail_rules_test.dart | 43 ++++++++ 4 files changed, 123 insertions(+), 26 deletions(-) diff --git a/flutter/lib/core/utils/korean_text.dart b/flutter/lib/core/utils/korean_text.dart index 41a7c68..78349e2 100644 --- a/flutter/lib/core/utils/korean_text.dart +++ b/flutter/lib/core/utils/korean_text.dart @@ -39,39 +39,93 @@ const int _noJongseong = 0; /// ㄹ 받침 — 방향 조사가 "로"다(서울로 · 메일로) const int _jongseongRieul = 8; -/// 마지막 글자의 받침 번호 — 한글 음절로 끝나지 않으면(영문 · 숫자 · 빈 글) null -int? _lastJongseong(String word) { +/// 한글 호환 자모의 자음 ㄱ~ㅎ(겹자음 포함) · 모음 ㅏ~ㅣ (U+3131~U+3163) +const int _jamoConsonantFirst = 0x3131; +const int _jamoConsonantLast = 0x314E; +const int _jamoVowelFirst = 0x314F; +const int _jamoVowelLast = 0x3163; + +/// 자음 ㄹ(리을) — 자음 가운데 방향 조사가 "로"인 것 +const int _jamoRieul = 0x3139; + +/// 숫자 0~9 (ASCII) +const int _digitZero = 0x30; +const int _digitNine = 0x39; + +/// 끝소리 — 조사를 고르는 데 쓴다 +enum _Coda { + /// 받침 없음 (프로젝트 · ㅏ · 2) + none, + + /// ㄹ 받침 — 방향 조사가 "로"다 (메일 · ㄹ · 1) + rieul, + + /// 그 밖의 받침 (휴지통 · ㄱ · 0) + other, +} + +/// 숫자 0~9 의 끝소리 — 읽는 소리 영 · 일 · 이 · 삼 · 사 · 오 · 육 · 칠 · 팔 · 구 +const List<_Coda> _digitCodas = <_Coda>[ + _Coda.other, // 영 + _Coda.rieul, // 일 + _Coda.none, // 이 + _Coda.other, // 삼 + _Coda.none, // 사 + _Coda.none, // 오 + _Coda.other, // 육 + _Coda.rieul, // 칠 + _Coda.rieul, // 팔 + _Coda.none, // 구 +]; + +/// 마지막 글자의 끝소리 — 앞뒤 공백은 떼고 본다(따옴표 · 괄호는 떼지 않는다). 소리를 알 수 없으면(라틴 글자 · 기호 · 빈 글) null +/// - 한글 음절: 그 받침 +/// - 한글 호환 자모: 자음은 받침 있음(이름 기역 · 니은 · 리을 … 이 모두 받침으로 끝난다 — ㄹ 은 [_Coda.rieul]), 모음은 받침 없음 +/// - 숫자: 읽는 소리로 ([_digitCodas]) +/// (웹 `korean.ts` 와 같은 규칙 — 2026-11-07 자모 · 숫자를 함께 넓혔다) +_Coda? _lastCoda(String word) { final String trimmed = word.trim(); if (trimmed.isEmpty) return null; final int last = trimmed.runes.last; - if (!_isHangulSyllable(last)) return null; - return (last - _hangulFirst) % _jongseongCount; + if (_isHangulSyllable(last)) { + final int jongseong = (last - _hangulFirst) % _jongseongCount; + if (jongseong == _noJongseong) return _Coda.none; + return jongseong == _jongseongRieul ? _Coda.rieul : _Coda.other; + } + if (last >= _jamoConsonantFirst && last <= _jamoConsonantLast) { + return last == _jamoRieul ? _Coda.rieul : _Coda.other; + } + if (last >= _jamoVowelFirst && last <= _jamoVowelLast) return _Coda.none; + if (last >= _digitZero && last <= _digitNine) { + return _digitCodas[last - _digitZero]; + } + return null; } -/// 주격 조사 "이/가" — 받침이 있으면 "이", 없으면 "가"(휴지통이 · 받은편지함이 · 프로젝트가). -/// 한글로 끝나지 않는 이름(영문 · 숫자)은 소리를 알 수 없어 "이(가)" 다 (웹 `korean.ts` 의 `subjectParticle` 과 같다). -String subjectParticle(String word) { - final int? jongseong = _lastJongseong(word); - if (jongseong == null) return '이(가)'; - return jongseong == _noJongseong ? '가' : '이'; -} +/// 주격 조사 "이/가" — 받침이 있으면 "이", 없으면 "가"(휴지통이 · 받은편지함이 · 프로젝트가 · ‘ㄱㅁ’이 · 2가). +/// 소리를 알 수 없는 끝 글자(라틴 글자 · 기호)면 "이(가)" 다 (웹 `korean.ts` 의 `subjectParticle` 과 같다). +String subjectParticle(String word) => switch (_lastCoda(word)) { + null => '이(가)', + _Coda.none => '가', + _Coda.rieul || _Coda.other => '이', +}; -/// 방향 조사 "으로/로" — 받침이 있으면 "으로", 없거나 ㄹ 받침이면 "로"(휴지통으로 · 보낸편지함으로 · 프로젝트로). -/// 한글로 끝나지 않는 이름은 "(으)로" 다 (웹 `korean.ts` 의 `directionParticle` 과 같다). -String directionParticle(String word) { - final int? jongseong = _lastJongseong(word); - if (jongseong == null) return '(으)로'; - return jongseong == _noJongseong || jongseong == _jongseongRieul ? '로' : '으로'; -} +/// 방향 조사 "으로/로" — 받침이 있으면 "으로", 없거나 ㄹ 받침이면 "로"(휴지통으로 · 보낸편지함으로 · 프로젝트로 · 프로젝트1로). +/// 소리를 알 수 없는 끝 글자면 "(으)로" 다 (웹 `korean.ts` 의 `directionParticle` 과 같다). +String directionParticle(String word) => switch (_lastCoda(word)) { + null => '(으)로', + _Coda.none || _Coda.rieul => '로', + _Coda.other => '으로', +}; /// 보조사 "은/는" — 받침이 있으면 "은", 없으면 "는"(사진은 · 보고서는 · 외 2개는). -/// 한글로 끝나지 않는 이름(영문 · 숫자 — 확장자로 끝나는 파일 이름)은 소리를 알 수 없어 "은(는)" 이다 +/// 소리를 알 수 없는 끝 글자(라틴 글자 · 기호 — 확장자로 끝나는 파일 이름)면 "은(는)" 이다 /// (앱에서 더함 — 웹 `korean.ts` 에는 없다. [subjectParticle] 과 같은 규칙) -String topicParticle(String word) { - final int? jongseong = _lastJongseong(word); - if (jongseong == null) return '은(는)'; - return jongseong == _noJongseong ? '는' : '은'; -} +String topicParticle(String word) => switch (_lastCoda(word)) { + null => '은(는)', + _Coda.none => '는', + _Coda.rieul || _Coda.other => '은', +}; /// 띄어쓰기 · 제어 문자를 뺀 ASCII 글자 — 영문 · 숫자 · 기호 bool _isAsciiGraphic(int rune) => rune >= 0x21 && rune <= 0x7E; diff --git a/flutter/lib/features/mail/presentation/mail_texts.dart b/flutter/lib/features/mail/presentation/mail_texts.dart index 67b0262..f0b798b 100644 --- a/flutter/lib/features/mail/presentation/mail_texts.dart +++ b/flutter/lib/features/mail/presentation/mail_texts.dart @@ -87,7 +87,7 @@ abstract final class MailComposeTexts { '$limit보다 큰 파일은 앱에서 아직 보낼 수 없습니다. 웹메일에서 대용량 첨부로 보내 주세요.'; /// 합계 한도를 넘게 만드는 파일 — "첨부를 모두 합치면 25MB를 넘어 ‘사진.jpg’은(는) 넣지 못했습니다. …" (계획). - /// 여럿이면 "‘사진.jpg’ 외 2개는"(앱에서 더함). 조사는 `topicParticle` — 확장자로 끝나는 이름은 소리를 몰라 "은(는)" + /// 여럿이면 "‘사진.jpg’ 외 2개는"(앱에서 더함). 조사는 `topicParticle` — 라틴 글자 확장자로 끝나는 이름은 소리를 몰라 "은(는)"(숫자로 끝나면 그 숫자의 소리로) static String overTotal(String total, String name, {int others = 0}) { final String target = others > 0 ? '‘$name’ 외 ${formatCount(others)}개' diff --git a/flutter/test/features/mail/compose_attachments_test.dart b/flutter/test/features/mail/compose_attachments_test.dart index 77098d2..8f5d13e 100644 --- a/flutter/test/features/mail/compose_attachments_test.dart +++ b/flutter/test/features/mail/compose_attachments_test.dart @@ -250,7 +250,7 @@ void main() { ); }); - test('은/는 — 받침에 맞추고, 한글로 끝나지 않으면 "은(는)"', () { + test('은/는 — 받침에 맞추고, 소리를 모르는 끝 글자(라틴 글자 · 기호)면 "은(는)"', () { expect(topicParticle('사진'), '은'); expect(topicParticle('보고서'), '는'); expect(topicParticle('외 2개'), '는'); diff --git a/flutter/test/features/mail/mail_rules_test.dart b/flutter/test/features/mail/mail_rules_test.dart index 9963852..d5ca0be 100644 --- a/flutter/test/features/mail/mail_rules_test.dart +++ b/flutter/test/features/mail/mail_rules_test.dart @@ -108,6 +108,49 @@ void main() { expect(directionParticle('프로젝트'), '로'); expect(directionParticle('Archive'), '(으)로'); }); + + test('한글 자모 · 숫자는 읽는 소리로 — 자음은 받침 있음(ㄹ 은 "로"), 모음은 받침 없음', () { + // 끝 글자 → 이/가 · 으로/로 · 은/는 + for (final (String last, String subject, String direction, String topic) + in <(String, String, String, String)>[ + ('ㄱ', '이', '으로', '은'), // 기역 + ('ㄹ', '이', '로', '은'), // 리을 + ('ㅁ', '이', '으로', '은'), // 미음 + ('ㄳ', '이', '으로', '은'), // 기역시옷 — 겹자음도 받침 있음 + ('ㅏ', '가', '로', '는'), // 아 + ('0', '이', '으로', '은'), // 영 + ('1', '이', '로', '은'), // 일 + ('2', '가', '로', '는'), // 이 + ('3', '이', '으로', '은'), // 삼 + ('4', '가', '로', '는'), // 사 + ('5', '가', '로', '는'), // 오 + ('6', '이', '으로', '은'), // 육 + ('7', '이', '로', '은'), // 칠 + ('8', '이', '로', '은'), // 팔 + ('9', '가', '로', '는'), // 구 + ]) { + expect(subjectParticle(last), subject, reason: last); + expect(directionParticle(last), direction, reason: last); + expect(topicParticle(last), topic, reason: last); + } + // 낱말의 끝 글자로 본다 — 초성 검색어 · 번호로 끝나는 이름 + expect(directionParticle('ㄱㅁ'), '으로'); + expect(subjectParticle('ㄱㅁ'), '이'); + expect(directionParticle('프로젝트1'), '로'); + expect(subjectParticle('2026'), '이'); + // 라틴 글자 · 기호는 소리를 몰라 지금처럼 + expect(subjectParticle('ARA'), '이(가)'); + expect(directionParticle('100%'), '(으)로'); + expect(topicParticle('v'), '은(는)'); + }); + + test('끝의 공백은 떼고 보고, 따옴표 · 괄호는 떼지 않는다 — 그 글자로 끝나면 소리를 몰라 "이(가)" 꼴', () { + expect(subjectParticle('계약 '), '이'); + expect(directionParticle('ㄹ '), '로'); + expect(subjectParticle('계약)'), '이(가)'); + expect(subjectParticle('"계약"'), '이(가)'); + expect(directionParticle('(2)'), '(으)로'); + }); }); group('여러 통 처리의 알림 글 (웹 mail-bulk.ts 와 같다)', () {