@ihabdevteam/core 0.350.0 → 0.352.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1754,7 +1754,7 @@ export declare function copyText(text: string): Promise<boolean>;
1754
1754
  *
1755
1755
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
1756
1756
  */
1757
- export declare const CORE_VERSION = "0.350.0";
1757
+ export declare const CORE_VERSION = "0.352.0";
1758
1758
 
1759
1759
  /** @public */
1760
1760
  declare const coreDefault: Record<string, unknown>;
@@ -2430,6 +2430,13 @@ export declare interface ErrorStateProps {
2430
2430
  */
2431
2431
  export declare function escapeCsvField(v: unknown): string;
2432
2432
 
2433
+ /**
2434
+ * EUC-KR 바이트 — 한글 2 · ASCII 1 · 줄바꿈 2.
2435
+ *
2436
+ * @public
2437
+ */
2438
+ export declare function eucKrBytes(text: string): number;
2439
+
2433
2440
  /**
2434
2441
  * 슬래시로 묶인 복합 약호(예: '명/관', '동/보동')를 라벨맵으로 각각 펼쳐 다시 '/'로 잇는다.
2435
2442
  * 라벨맵에 없는 조각은 원문 그대로 둔다. 빈 문자열은 빈 문자열 그대로 반환.
@@ -3736,6 +3743,9 @@ export declare function ListPopup<T extends {
3736
3743
  /** 첫 쪽으로 돌아갈 까닭을 잇는 값 — **글로 적을 수 있는 것**만 받는다(아래 `resetOn` 참고) @public */
3737
3744
  export declare type ListResetValue = string | number | boolean | null | undefined;
3738
3745
 
3746
+ /** 장문(LMS) 본문 한계 */
3747
+ export declare const LMS_BYTE_LIMIT = 2000;
3748
+
3739
3749
  /** @public */
3740
3750
  export declare function Loader(props: LoaderProps): JSX.Element;
3741
3751
 
@@ -3877,6 +3887,21 @@ export declare function matchSyllables(word: string, pronMap: Map<string, string
3877
3887
  /** 단어+동음이의어 번호 → 뜻 조회 키 */
3878
3888
  export declare const meaningKey: (word: string, homonum?: number) => string;
3879
3889
 
3890
+ /**
3891
+ * 글 하나를 재서 **쓰는 쪽이 그릴 만큼** 돌려준다.
3892
+ *
3893
+ * `over` 는 *"한계를 넘었나"* 다 — 단문이면 90, 장문이면 2000 을 기준으로 본다.
3894
+ * 장문까지 넘으면 보내는 쪽에서 **잘린다.**
3895
+ *
3896
+ * @public
3897
+ */
3898
+ export declare function measureSmsText(text: string): {
3899
+ bytes: number;
3900
+ type: SmsType;
3901
+ limit: number;
3902
+ over: boolean;
3903
+ };
3904
+
3880
3905
  /**
3881
3906
  * 미디어쿼리 결과를 강제로 덮어쓰기 위한 컨텍스트.
3882
3907
  * 랜딩페이지 폰 목업처럼 데스크탑 뷰포트에서 모바일 화면을 재현해야 할 때,
@@ -4206,6 +4231,73 @@ export declare interface NoticeBoxProps {
4206
4231
  className?: string;
4207
4232
  }
4208
4233
 
4234
+ /**
4235
+ * **보내는 알림 글을 한자리에서 본다** — 말귀 청 (314), 2026-10-21.
4236
+ *
4237
+ * 말귀가 보내는 문자 글이 **여섯 파일에 흩어져** 있었다. 그래서 방문 알림만 머리말이
4238
+ * `[기관명]` 으로 다른 것을(나머지 일곱은 `[말귀]`) **다음 날에야** 알았다. 한눈에 보이면
4239
+ * 그 자리에서 보였을 흠이다.
4240
+ *
4241
+ * ## 글은 **쓰는 쪽이 넘긴다**
4242
+ *
4243
+ * 문안은 서비스마다 다르고 그쪽 저장소에 산다. **코어가 글을 들고 있으면 반드시 낡는다** —
4244
+ * 그래서 이 블록은 받아서 그리기만 한다. 코어가 쥐는 것은 **재는 자**(`measureSmsText`)와
4245
+ * **그리는 꼴**뿐이다.
4246
+ *
4247
+ * ## 바이트가 요점이다
4248
+ *
4249
+ * **90바이트**가 단문(18원)과 장문(45원)을 가르는데 한글이 2바이트라 **글자 수로는 가늠이
4250
+ * 안 된다**(45자가 90바이트고, 영문이 섞이면 또 달라진다). 그래서 글마다 바이트와 갈래를
4251
+ * 적고, 한계를 넘은 줄은 눈에 띄게 둔다.
4252
+ *
4253
+ * @example
4254
+ * ```tsx
4255
+ * <NotifyTextPreview items={[{
4256
+ * name: '인증번호',
4257
+ * meta: { '받는 이': '가입하는 사람', '언제': '인증 단추를 누를 때' },
4258
+ * sms: '[말귀] 인증번호 123456 를 입력해 주세요. 5분 안에 유효합니다.',
4259
+ * ata: '[말귀] 인증번호 안내\n인증번호: 123456',
4260
+ * }]} />
4261
+ * ```
4262
+ *
4263
+ * @public
4264
+ */
4265
+ export declare interface NotifyTextItem {
4266
+ /** 갈래 이름 — 목록에서 이것으로 찾는다 */
4267
+ name: string;
4268
+ /**
4269
+ * 곁말 — **이름과 값의 짝**을 적은 차례대로 그린다(`받는 이` · `언제` · `고치는 곳` …).
4270
+ *
4271
+ * 칸 이름을 박지 않는 까닭: 서비스마다 적을 것이 다르다. 코어가 `to`·`when` 처럼 박으면
4272
+ * *"보내는 함수"* 나 *"알림톡 환경변수"* 를 적을 자리가 없어 쓰는 쪽이 다시 짜게 된다.
4273
+ */
4274
+ meta?: Record<string, string>;
4275
+ /** 문자 본문 — 바이트와 갈래를 여기서 잰다 */
4276
+ sms: string;
4277
+ /** 알림톡 본문. 없으면 그 칸을 안 그린다 */
4278
+ ata?: string;
4279
+ }
4280
+
4281
+ /** @public */
4282
+ export declare function NotifyTextPreview({ items, className, ariaLabel, titleLevel }: NotifyTextPreviewProps): JSX.Element;
4283
+
4284
+ /** @public */
4285
+ export declare interface NotifyTextPreviewProps {
4286
+ items: NotifyTextItem[];
4287
+ className?: string;
4288
+ /**
4289
+ * 읽어 줄 이름. 묶음에 이름이 없으면 낭독기가 **묶음을 아예 안 알린다**
4290
+ * (`ColorPicker` 의 주석과 같은 까닭).
4291
+ */
4292
+ ariaLabel?: string;
4293
+ /**
4294
+ * 갈래 이름의 머리글 급. **박지 않고 연다** — 이 블록은 담는 것 안에 서므로 묶음 급이
4295
+ * 바뀌면 따라 내려가야 한다(`ChartSection` 과 같은 규약). `0` 이면 머리글이 아니라
4296
+ * `div` 로 그린다.
4297
+ */
4298
+ titleLevel?: 0 | 2 | 3 | 4 | 5 | 6;
4299
+ }
4300
+
4209
4301
  /**
4210
4302
  * @public 정식 음원이 저장소에 놓이는 자리.
4211
4303
  *
@@ -7028,6 +7120,21 @@ export declare interface SliderProps {
7028
7120
  tickShape?: 'dot' | 'dash';
7029
7121
  }
7030
7122
 
7123
+ /** 단문(SMS) 한 통에 드는 한계 — 넘으면 장문(LMS)이다 */
7124
+ export declare const SMS_BYTE_LIMIT = 90;
7125
+
7126
+ /** 보내는 갈래 */
7127
+ export declare type SmsType = 'SMS' | 'LMS';
7128
+
7129
+ /**
7130
+ * 단문이냐 장문이냐 — **직접 정한다.**
7131
+ *
7132
+ * 보내는 쪽이 알아서 바꿔 주기도 하지만, 우리가 정하면 **어느 값으로 보냈는지 자취에 남는다.**
7133
+ *
7134
+ * @public
7135
+ */
7136
+ export declare function smsTypeFor(text: string): SmsType;
7137
+
7031
7138
  /**
7032
7139
  * 정렬 상태를 드롭다운 **한 문자열**로 주고받는 부호 — `"name|asc"` 꼴.
7033
7140
  *
@@ -1,4 +1,11 @@
1
1
  .checkbox {
2
+ /* **감싸개가 자리를 잡아야 한다.** 숨긴 `.checkbox__input` 이 `position: absolute` 인데
3
+ 여기 `position` 이 없으면 그 1px 칸이 **가장 가까운 자리 잡힌 조상**(창의 스크롤 통)
4
+ 맨 위에 놓인다. 이름표를 누르면 그 숨은 칸이 초점을 받고, 브라우저가 그것을 보이려고
5
+ **스크롤을 맨 위로 올린다** — 폰에서 가입 창의 동의 칸을 누르면 창이 튀었다
6
+ (사장님 신고 2026-10-20, 말귀가 실제 폰에서 재현하고 자리를 짚었다).
7
+ 긴 폼 어디서든 같은 일이 난다. `.radio`·`.toggle` 은 진작 이것을 갖고 있었다. */
8
+ position: relative;
2
9
  display: inline-flex;
3
10
  align-items: center;
4
11
  gap: 8px;
@@ -0,0 +1,81 @@
1
+ /* **알림 글 미리보기**(말귀 청 (314)) — 글은 쓰는 쪽이 넘기고 여기서는 그리기만 한다.
2
+ 글자 크기는 `styles.css` 의 공용 클래스(`h5b`·`caption-1`)로 받는다 — 컴포넌트 CSS 에
3
+ 크기를 적으면 저장소의 글자 체계 밖으로 나간다. */
4
+
5
+ .notify-text-preview {
6
+ display: flex;
7
+ flex-direction: column;
8
+ gap: var(--spacing-l);
9
+ }
10
+
11
+ .notify-text-preview__item {
12
+ display: flex;
13
+ flex-direction: column;
14
+ gap: 10px;
15
+ padding: var(--spacing-m);
16
+ border: 1px solid var(--color-darkWhite);
17
+ border-radius: 12px;
18
+ }
19
+
20
+ .notify-text-preview__head {
21
+ display: flex;
22
+ flex-direction: column;
23
+ }
24
+
25
+ .notify-text-preview__name {
26
+ margin: 0 0 6px;
27
+ }
28
+
29
+ /* 곁말 — 이름과 값이 짝으로 선다. `<dl>` 안에서 짝을 묶으려면 `<div>` 로 감싼다(HTML 규약) */
30
+ .notify-text-preview__meta {
31
+ margin: 0;
32
+ color: var(--color-gray);
33
+ }
34
+ .notify-text-preview__meta-row {
35
+ display: grid;
36
+ grid-template-columns: auto 1fr;
37
+ gap: 10px;
38
+ }
39
+ .notify-text-preview__meta dt { margin: 0; }
40
+ .notify-text-preview__meta dd { margin: 0; min-width: 0; word-break: break-all; }
41
+
42
+ /* 문자와 알림톡을 **나란히** — 좁은 칸에서는 위아래로 접힌다 */
43
+ .notify-text-preview__body {
44
+ display: grid;
45
+ grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
46
+ gap: 10px;
47
+ }
48
+
49
+ .notify-text-preview__col {
50
+ display: flex;
51
+ flex-direction: column;
52
+ gap: 4px;
53
+ min-width: 0; /* 긴 글이 칸을 밀어내지 않게 */
54
+ }
55
+
56
+ .notify-text-preview__col-head {
57
+ display: flex;
58
+ justify-content: space-between;
59
+ align-items: baseline;
60
+ gap: 8px;
61
+ color: var(--color-gray);
62
+ }
63
+
64
+ .notify-text-preview__bytes { white-space: nowrap; }
65
+
66
+ /* 한계를 넘은 줄 — **색만으로 말하지 않는다.** 글(`한계 넘음`)이 함께 간다. */
67
+ .notify-text-preview__bytes.is-over {
68
+ color: var(--color-error);
69
+ font-weight: 600;
70
+ }
71
+
72
+ .notify-text-preview__text {
73
+ margin: 0;
74
+ padding: 10px;
75
+ background: var(--color-deepWhite);
76
+ border-radius: 8px;
77
+ /* 글은 적힌 그대로 — 줄바꿈이 바이트를 바꾸므로 접지 않고 보여 준다 */
78
+ white-space: pre-wrap;
79
+ word-break: break-word;
80
+ font: inherit;
81
+ }
@@ -0,0 +1,65 @@
1
+ import './NotifyTextPreview.css';
2
+ /**
3
+ * **보내는 알림 글을 한자리에서 본다** — 말귀 청 (314), 2026-10-21.
4
+ *
5
+ * 말귀가 보내는 문자 글이 **여섯 파일에 흩어져** 있었다. 그래서 방문 알림만 머리말이
6
+ * `[기관명]` 으로 다른 것을(나머지 일곱은 `[말귀]`) **다음 날에야** 알았다. 한눈에 보이면
7
+ * 그 자리에서 보였을 흠이다.
8
+ *
9
+ * ## 글은 **쓰는 쪽이 넘긴다**
10
+ *
11
+ * 문안은 서비스마다 다르고 그쪽 저장소에 산다. **코어가 글을 들고 있으면 반드시 낡는다** —
12
+ * 그래서 이 블록은 받아서 그리기만 한다. 코어가 쥐는 것은 **재는 자**(`measureSmsText`)와
13
+ * **그리는 꼴**뿐이다.
14
+ *
15
+ * ## 바이트가 요점이다
16
+ *
17
+ * **90바이트**가 단문(18원)과 장문(45원)을 가르는데 한글이 2바이트라 **글자 수로는 가늠이
18
+ * 안 된다**(45자가 90바이트고, 영문이 섞이면 또 달라진다). 그래서 글마다 바이트와 갈래를
19
+ * 적고, 한계를 넘은 줄은 눈에 띄게 둔다.
20
+ *
21
+ * @example
22
+ * ```tsx
23
+ * <NotifyTextPreview items={[{
24
+ * name: '인증번호',
25
+ * meta: { '받는 이': '가입하는 사람', '언제': '인증 단추를 누를 때' },
26
+ * sms: '[말귀] 인증번호 123456 를 입력해 주세요. 5분 안에 유효합니다.',
27
+ * ata: '[말귀] 인증번호 안내\n인증번호: 123456',
28
+ * }]} />
29
+ * ```
30
+ *
31
+ * @public
32
+ */
33
+ export interface NotifyTextItem {
34
+ /** 갈래 이름 — 목록에서 이것으로 찾는다 */
35
+ name: string;
36
+ /**
37
+ * 곁말 — **이름과 값의 짝**을 적은 차례대로 그린다(`받는 이` · `언제` · `고치는 곳` …).
38
+ *
39
+ * 칸 이름을 박지 않는 까닭: 서비스마다 적을 것이 다르다. 코어가 `to`·`when` 처럼 박으면
40
+ * *"보내는 함수"* 나 *"알림톡 환경변수"* 를 적을 자리가 없어 쓰는 쪽이 다시 짜게 된다.
41
+ */
42
+ meta?: Record<string, string>;
43
+ /** 문자 본문 — 바이트와 갈래를 여기서 잰다 */
44
+ sms: string;
45
+ /** 알림톡 본문. 없으면 그 칸을 안 그린다 */
46
+ ata?: string;
47
+ }
48
+ /** @public */
49
+ export interface NotifyTextPreviewProps {
50
+ items: NotifyTextItem[];
51
+ className?: string;
52
+ /**
53
+ * 읽어 줄 이름. 묶음에 이름이 없으면 낭독기가 **묶음을 아예 안 알린다**
54
+ * (`ColorPicker` 의 주석과 같은 까닭).
55
+ */
56
+ ariaLabel?: string;
57
+ /**
58
+ * 갈래 이름의 머리글 급. **박지 않고 연다** — 이 블록은 담는 것 안에 서므로 묶음 급이
59
+ * 바뀌면 따라 내려가야 한다(`ChartSection` 과 같은 규약). `0` 이면 머리글이 아니라
60
+ * `div` 로 그린다.
61
+ */
62
+ titleLevel?: 0 | 2 | 3 | 4 | 5 | 6;
63
+ }
64
+ /** @public */
65
+ export default function NotifyTextPreview({ items, className, ariaLabel, titleLevel }: NotifyTextPreviewProps): import("react").JSX.Element;
@@ -0,0 +1,15 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { measureSmsText } from '../utils/smsBytes';
4
+ import './NotifyTextPreview.css';
5
+ /** @public */
6
+ export default function NotifyTextPreview({ items, className, ariaLabel = '알림 글', titleLevel = 4 }) {
7
+ /* 글자 꼴(`h5b`)과 머리글 급(`titleLevel`)은 **다른 것**이다 — 급은 바깥 묶음이 정한다 */
8
+ const TitleTag = titleLevel ? `h${titleLevel}` : 'div';
9
+ return (_jsx("div", { className: `notify-text-preview${className ? ` ${className}` : ''}`, role: "group", "aria-label": ariaLabel, children: items.map((item) => {
10
+ const m = measureSmsText(item.sms);
11
+ return (_jsxs("section", { className: "notify-text-preview__item", children: [_jsxs("header", { className: "notify-text-preview__head", children: [_jsx(TitleTag, { className: "notify-text-preview__name h5b", children: item.name }), item.meta && Object.keys(item.meta).length > 0 ? (_jsx("dl", { className: "notify-text-preview__meta caption-1", children: Object.entries(item.meta).map(([k, v]) => (_jsxs("div", { className: "notify-text-preview__meta-row", children: [_jsx("dt", { children: k }), _jsx("dd", { children: v })] }, k))) })) : null] }), _jsxs("div", { className: "notify-text-preview__body", children: [_jsxs("div", { className: "notify-text-preview__col", children: [_jsxs("div", { className: "notify-text-preview__col-head caption-1", children: [_jsx("span", { children: "\uBB38\uC790" }), _jsxs("span", { className: `notify-text-preview__bytes${m.over ? ' is-over' : ''}`,
12
+ /* 넘은 것은 색만으로 말하지 않는다 — 색을 못 보는 사람에게도 글로 간다 */
13
+ title: m.over ? `${m.limit}바이트를 넘었다 — 보낼 때 잘린다` : undefined, children: [m.bytes, "\uBC14\uC774\uD2B8 \u00B7 ", m.type, m.over ? ' · 한계 넘음' : ''] })] }), _jsx("pre", { className: "notify-text-preview__text", children: item.sms })] }), item.ata ? (_jsxs("div", { className: "notify-text-preview__col", children: [_jsx("div", { className: "notify-text-preview__col-head caption-1", children: _jsx("span", { children: "\uC54C\uB9BC\uD1A1" }) }), _jsx("pre", { className: "notify-text-preview__text", children: item.ata })] })) : null] })] }, item.name));
14
+ }) }));
15
+ }
@@ -140,6 +140,8 @@ export { default as DatePicker } from './components/DatePicker';
140
140
  export type { DatePickerProps } from './components/DatePicker';
141
141
  export { useCalendarMonth } from './components/useCalendarMonth';
142
142
  export type { UseCalendarMonthResult } from './components/useCalendarMonth';
143
+ export { default as NotifyTextPreview } from './components/NotifyTextPreview';
144
+ export type { NotifyTextPreviewProps, NotifyTextItem } from './components/NotifyTextPreview';
143
145
  export { default as BrandLogo } from './components/BrandLogo';
144
146
  export type { BrandLogoProps } from './components/BrandLogo';
145
147
  export { default as BarChart, BarChartControls } from './components/BarChart';
@@ -93,6 +93,9 @@ export { useCalendarMonth } from './components/useCalendarMonth';
93
93
  /* 브랜드 로고 — 그대로 · 한 톤(`tone`) · 글자만 다른 색(`textColor`).
94
94
  2026-10-15 에 로고 파일에서 `_white`·`_black`·`_textWhite` 가 없어지면서,
95
95
  그 색들을 **파일이 아니라 여기서** 내기로 했다. */
96
+ /* 보내는 알림 글을 한자리에서 본다 — 글은 **쓰는 쪽이 넘긴다**(말귀 청 (314)).
97
+ 코어가 쥐는 것은 재는 자(`measureSmsText`)와 그리는 꼴뿐이다. */
98
+ export { default as NotifyTextPreview } from './components/NotifyTextPreview';
96
99
  export { default as BrandLogo } from './components/BrandLogo';
97
100
  export { default as BarChart, BarChartControls } from './components/BarChart';
98
101
  export { default as LineChart, LINE_CHART_HOVER_DELAY_MS } from './components/LineChart';
@@ -0,0 +1,59 @@
1
+ /**
2
+ * **문자 글의 길이를 바이트로 잰다** — 말귀 청 (314), 2026-10-21.
3
+ *
4
+ * 문자는 **90바이트**에서 단문(18원)과 장문(45원)이 갈린다. 한글이 2바이트라
5
+ * **글자 수로는 가늠이 안 된다** — 45자가 90바이트고, 영문이 섞이면 또 달라진다.
6
+ * 값이 두 배 넘게 갈리는 자리라 짐작으로 둘 곳이 아니다.
7
+ *
8
+ * ## 왜 코어가 드나
9
+ *
10
+ * 같은 셈이 **말귀에 한 벌, 여기에 한 벌**로 갈릴 자리였다(말귀 `api/_lib/smsForms.ts`).
11
+ * 한 결정이 두 곳에 적히면 한쪽만 고쳐지는 날이 온다 — 그때 요금 갈래가 조용히 어긋난다.
12
+ * 말귀가 제 것을 걷고 이것을 쓰기로 했으므로 **뜻이 한 글자도 다르면 안 된다.**
13
+ *
14
+ * ## 참 EUC-KR 과 다른 자리 — 알고 둔다
15
+ *
16
+ * 이것은 참 EUC-KR 인코더가 아니라 **그 자가 재는 값을 흉내 낸 셈**이다:
17
+ *
18
+ * · **줄바꿈은 2**로 센다 — 보낼 때 `\r\n` 으로 나가기 때문이다. 그래서 글에 이미
19
+ * `\r\n` 이 들어 있으면 **4로 세어 실제보다 크게** 잡는다. 크게 잡는 쪽은 **안전하다**
20
+ * (단문에 들 것을 장문으로 보낼 뿐, 그 반대가 아니다). 작게 잡으면 보내는 쪽에서 잘린다.
21
+ * · **EUC-KR 에 없는 글자**(이모지 따위)도 2로 센다. 참 EUC-KR 은 그것을 아예 못 담아
22
+ * 보내는 쪽이 거르거나 바꾼다 — 길이보다 **담기는지**가 먼저인 자리라 여기서 안 다룬다.
23
+ *
24
+ * 두 자리 다 말귀가 쓰던 셈 그대로다. 고치려면 **양쪽을 같은 날 고친다.**
25
+ */
26
+ /** 단문(SMS) 한 통에 드는 한계 — 넘으면 장문(LMS)이다 */
27
+ export declare const SMS_BYTE_LIMIT = 90;
28
+ /** 장문(LMS) 본문 한계 */
29
+ export declare const LMS_BYTE_LIMIT = 2000;
30
+ /** 보내는 갈래 */
31
+ export type SmsType = 'SMS' | 'LMS';
32
+ /**
33
+ * EUC-KR 바이트 — 한글 2 · ASCII 1 · 줄바꿈 2.
34
+ *
35
+ * @public
36
+ */
37
+ export declare function eucKrBytes(text: string): number;
38
+ /**
39
+ * 단문이냐 장문이냐 — **직접 정한다.**
40
+ *
41
+ * 보내는 쪽이 알아서 바꿔 주기도 하지만, 우리가 정하면 **어느 값으로 보냈는지 자취에 남는다.**
42
+ *
43
+ * @public
44
+ */
45
+ export declare function smsTypeFor(text: string): SmsType;
46
+ /**
47
+ * 글 하나를 재서 **쓰는 쪽이 그릴 만큼** 돌려준다.
48
+ *
49
+ * `over` 는 *"한계를 넘었나"* 다 — 단문이면 90, 장문이면 2000 을 기준으로 본다.
50
+ * 장문까지 넘으면 보내는 쪽에서 **잘린다.**
51
+ *
52
+ * @public
53
+ */
54
+ export declare function measureSmsText(text: string): {
55
+ bytes: number;
56
+ type: SmsType;
57
+ limit: number;
58
+ over: boolean;
59
+ };
@@ -0,0 +1,69 @@
1
+ /**
2
+ * **문자 글의 길이를 바이트로 잰다** — 말귀 청 (314), 2026-10-21.
3
+ *
4
+ * 문자는 **90바이트**에서 단문(18원)과 장문(45원)이 갈린다. 한글이 2바이트라
5
+ * **글자 수로는 가늠이 안 된다** — 45자가 90바이트고, 영문이 섞이면 또 달라진다.
6
+ * 값이 두 배 넘게 갈리는 자리라 짐작으로 둘 곳이 아니다.
7
+ *
8
+ * ## 왜 코어가 드나
9
+ *
10
+ * 같은 셈이 **말귀에 한 벌, 여기에 한 벌**로 갈릴 자리였다(말귀 `api/_lib/smsForms.ts`).
11
+ * 한 결정이 두 곳에 적히면 한쪽만 고쳐지는 날이 온다 — 그때 요금 갈래가 조용히 어긋난다.
12
+ * 말귀가 제 것을 걷고 이것을 쓰기로 했으므로 **뜻이 한 글자도 다르면 안 된다.**
13
+ *
14
+ * ## 참 EUC-KR 과 다른 자리 — 알고 둔다
15
+ *
16
+ * 이것은 참 EUC-KR 인코더가 아니라 **그 자가 재는 값을 흉내 낸 셈**이다:
17
+ *
18
+ * · **줄바꿈은 2**로 센다 — 보낼 때 `\r\n` 으로 나가기 때문이다. 그래서 글에 이미
19
+ * `\r\n` 이 들어 있으면 **4로 세어 실제보다 크게** 잡는다. 크게 잡는 쪽은 **안전하다**
20
+ * (단문에 들 것을 장문으로 보낼 뿐, 그 반대가 아니다). 작게 잡으면 보내는 쪽에서 잘린다.
21
+ * · **EUC-KR 에 없는 글자**(이모지 따위)도 2로 센다. 참 EUC-KR 은 그것을 아예 못 담아
22
+ * 보내는 쪽이 거르거나 바꾼다 — 길이보다 **담기는지**가 먼저인 자리라 여기서 안 다룬다.
23
+ *
24
+ * 두 자리 다 말귀가 쓰던 셈 그대로다. 고치려면 **양쪽을 같은 날 고친다.**
25
+ */
26
+ /** 단문(SMS) 한 통에 드는 한계 — 넘으면 장문(LMS)이다 */
27
+ export const SMS_BYTE_LIMIT = 90;
28
+ /** 장문(LMS) 본문 한계 */
29
+ export const LMS_BYTE_LIMIT = 2000;
30
+ /**
31
+ * EUC-KR 바이트 — 한글 2 · ASCII 1 · 줄바꿈 2.
32
+ *
33
+ * @public
34
+ */
35
+ export function eucKrBytes(text) {
36
+ let bytes = 0;
37
+ for (const ch of text) {
38
+ const code = ch.codePointAt(0) ?? 0;
39
+ if (ch === '\n' || ch === '\r')
40
+ bytes += 2;
41
+ else
42
+ bytes += code < 0x80 ? 1 : 2;
43
+ }
44
+ return bytes;
45
+ }
46
+ /**
47
+ * 단문이냐 장문이냐 — **직접 정한다.**
48
+ *
49
+ * 보내는 쪽이 알아서 바꿔 주기도 하지만, 우리가 정하면 **어느 값으로 보냈는지 자취에 남는다.**
50
+ *
51
+ * @public
52
+ */
53
+ export function smsTypeFor(text) {
54
+ return eucKrBytes(text) > SMS_BYTE_LIMIT ? 'LMS' : 'SMS';
55
+ }
56
+ /**
57
+ * 글 하나를 재서 **쓰는 쪽이 그릴 만큼** 돌려준다.
58
+ *
59
+ * `over` 는 *"한계를 넘었나"* 다 — 단문이면 90, 장문이면 2000 을 기준으로 본다.
60
+ * 장문까지 넘으면 보내는 쪽에서 **잘린다.**
61
+ *
62
+ * @public
63
+ */
64
+ export function measureSmsText(text) {
65
+ const bytes = eucKrBytes(text);
66
+ const type = smsTypeFor(text);
67
+ const limit = type === 'SMS' ? SMS_BYTE_LIMIT : LMS_BYTE_LIMIT;
68
+ return { bytes, type, limit, over: bytes > limit };
69
+ }
@@ -6,6 +6,8 @@ export { configureTTS, getTTSUrlResolver, getTTSFallbackUrlResolver, clearAudioE
6
6
  export { configurePronunciationResolver, getPronunciationResolver } from './utils/pronunciationConfig';
7
7
  export { getElementAnalyser, resumeAudioMeter, readLevel, routeElementAudio, setElementVolume, getElementVolume } from './utils/audioMeter';
8
8
  export { configureImageResolver, getImageResolver, checkImageExists, getImageExistsCached, prewarmImageCache, bulkSetImageExistence, clearImageExistenceCache, getImageExistenceStats } from './utils/imageConfig';
9
+ export { eucKrBytes, smsTypeFor, measureSmsText, SMS_BYTE_LIMIT, LMS_BYTE_LIMIT } from './utils/smsBytes';
10
+ export type { SmsType } from './utils/smsBytes';
9
11
  export { buildPairIndex, getPairIndex, clearPairIndexCache } from './utils/wordPairIndex';
10
12
  export type { PairIndex, PairIndexEntry, PairInput } from './utils/wordPairIndex';
11
13
  export { minimalPairDistractors, filterContrastCandidates, hasStructureContrast, buildMultiPartPosContrast, hasSel } from './utils/wordContrast';
@@ -5,6 +5,9 @@ export { configureTTS, getTTSUrlResolver, getTTSFallbackUrlResolver, clearAudioE
5
5
  export { configurePronunciationResolver, getPronunciationResolver } from './utils/pronunciationConfig';
6
6
  export { getElementAnalyser, resumeAudioMeter, readLevel, routeElementAudio, setElementVolume, getElementVolume } from './utils/audioMeter';
7
7
  export { configureImageResolver, getImageResolver, checkImageExists, getImageExistsCached, prewarmImageCache, bulkSetImageExistence, clearImageExistenceCache, getImageExistenceStats } from './utils/imageConfig';
8
+ /* 문자 글의 바이트 — 90바이트가 단문(18원)과 장문(45원)을 가른다(말귀 청 (314)).
9
+ 말귀가 제 셈을 걷고 이것을 쓴다 — 한 결정이 두 곳에 적히지 않게. */
10
+ export { eucKrBytes, smsTypeFor, measureSmsText, SMS_BYTE_LIMIT, LMS_BYTE_LIMIT } from './utils/smsBytes';
8
11
  export { buildPairIndex, getPairIndex, clearPairIndexCache } from './utils/wordPairIndex';
9
12
  export { minimalPairDistractors, filterContrastCandidates, hasStructureContrast, buildMultiPartPosContrast, hasSel } from './utils/wordContrast';
10
13
  export { parseSyllable, composeSyllable, createDecompCache, CHOSEONG, JUNGSEONG, JONGSEONG } from './utils/koreanSyllable';
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export declare const CORE_VERSION = "0.350.0";
49
+ export declare const CORE_VERSION = "0.352.0";
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export const CORE_VERSION = '0.350.0';
49
+ export const CORE_VERSION = '0.352.0';