Skip to main content

mdwire/
width.rs

1//! 표시 폭 — 고정폭 글꼴에서 한 글자가 차지하는 칸 수.
2//!
3//! **표의 열은 문자 수가 아니라 표시 폭으로 맞춘다**(`SPEC.md` 7절). 한글·한자·가나·
4//! 전각기호·이모지는 두 칸을 차지한다. `chars().count()` 로 맞추면 한글이 든 표는
5//! 반드시 어긋난다 — 절반쯤 어긋나는 것이 아니라, 열 하나에 한글 한 글자가 들어갈 때마다
6//! 한 칸씩 밀린다.
7//!
8//! 유니코드 East Asian Width 의 `W`·`F` 를 폭 2 로, 결합 문자와 폭 없는 제어 문자를
9//! 폭 0 으로 본다. 코어에 의존성을 두지 않으므로 구간표를 직접 들고 있다
10//! (`AGENTS.md`). 전각 구간은 좁아서 표로 박아도 유지가 된다.
11
12/// 폭 0 — 결합 문자, 폭 없는 공백, 변형 선택자.
13const ZERO: &[(u32, u32)] = &[
14    (0x0300, 0x036F), // 결합 발음 기호
15    (0x0483, 0x0489),
16    (0x0591, 0x05BD),
17    (0x0610, 0x061A),
18    (0x064B, 0x065F),
19    (0x0670, 0x0670),
20    (0x06D6, 0x06DC),
21    (0x0E31, 0x0E31),
22    (0x0E34, 0x0E3A),
23    (0x0E47, 0x0E4E),
24    (0x1160, 0x11FF), // 한글 중성·종성 자모(조합용). 초성에 붙어 폭을 더하지 않는다
25    (0x135D, 0x135F),
26    (0x1AB0, 0x1AFF),
27    (0x1DC0, 0x1DFF),
28    (0x200B, 0x200F), // ZWSP·ZWNJ·ZWJ·방향 표시
29    (0x2028, 0x202E),
30    (0x2060, 0x2064),
31    (0x20D0, 0x20F0),
32    (0xFE00, 0xFE0F), // 변형 선택자
33    (0xFE20, 0xFE2F),
34    (0xFEFF, 0xFEFF),
35    (0x1D165, 0x1D169),
36    (0x1D16D, 0x1D172),
37    (0xE0100, 0xE01EF),
38];
39
40/// 폭 2 — East Asian Width 가 Wide 또는 Fullwidth 인 구간.
41const WIDE: &[(u32, u32)] = &[
42    (0x1100, 0x115F), // 한글 초성 자모
43    (0x231A, 0x231B),
44    (0x2329, 0x232A),
45    (0x23E9, 0x23EC),
46    (0x23F0, 0x23F0),
47    (0x23F3, 0x23F3),
48    (0x25FD, 0x25FE),
49    (0x2614, 0x2615),
50    (0x2648, 0x2653),
51    (0x267F, 0x267F),
52    (0x2693, 0x2693),
53    (0x26A1, 0x26A1),
54    (0x26AA, 0x26AB),
55    (0x26BD, 0x26BE),
56    (0x26C4, 0x26C5),
57    (0x26CE, 0x26CE),
58    (0x26D4, 0x26D4),
59    (0x26EA, 0x26EA),
60    (0x26F2, 0x26F3),
61    (0x26F5, 0x26F5),
62    (0x26FA, 0x26FA),
63    (0x26FD, 0x26FD),
64    (0x2705, 0x2705),
65    (0x270A, 0x270B),
66    (0x2728, 0x2728),
67    (0x274C, 0x274C),
68    (0x274E, 0x274E),
69    (0x2753, 0x2755),
70    (0x2757, 0x2757),
71    (0x2795, 0x2797),
72    (0x27B0, 0x27B0),
73    (0x27BF, 0x27BF),
74    (0x2B1B, 0x2B1C),
75    (0x2B50, 0x2B50),
76    (0x2B55, 0x2B55),
77    (0x2E80, 0x2E99),
78    (0x2E9B, 0x2EF3),
79    (0x2F00, 0x2FD5),
80    (0x2FF0, 0x2FFB),
81    (0x3000, 0x303E), // 전각 공백 · CJK 구두점
82    (0x3041, 0x3096), // 히라가나
83    (0x3099, 0x30FF), // 가타카나
84    (0x3105, 0x312F),
85    (0x3131, 0x318E), // 한글 호환 자모
86    (0x3190, 0x31E3),
87    (0x31F0, 0x321E),
88    (0x3220, 0x3247),
89    (0x3250, 0x4DBF),
90    (0x4E00, 0xA48C), // 한중일 통합 한자
91    (0xA490, 0xA4C6),
92    (0xA960, 0xA97C),
93    (0xAC00, 0xD7A3), // 한글 음절 — 한국어 표의 거의 전부가 여기다
94    (0xF900, 0xFAFF),
95    (0xFE10, 0xFE19),
96    (0xFE30, 0xFE52),
97    (0xFE54, 0xFE66),
98    (0xFE68, 0xFE6B),
99    (0xFF01, 0xFF60), // 전각 영숫자·기호
100    (0xFFE0, 0xFFE6),
101    (0x16FE0, 0x16FE4),
102    (0x17000, 0x18CD5),
103    (0x1B000, 0x1B152),
104    (0x1B164, 0x1B167),
105    (0x1B170, 0x1B2FB),
106    (0x1F004, 0x1F004),
107    (0x1F0CF, 0x1F0CF),
108    (0x1F18E, 0x1F18E),
109    (0x1F191, 0x1F19A),
110    (0x1F200, 0x1F320),
111    (0x1F32D, 0x1F335),
112    (0x1F337, 0x1F37C),
113    (0x1F37E, 0x1F393),
114    (0x1F3A0, 0x1F3CA),
115    (0x1F3CF, 0x1F3D3),
116    (0x1F3E0, 0x1F3F0),
117    (0x1F3F4, 0x1F3F4),
118    (0x1F3F8, 0x1F43E),
119    (0x1F440, 0x1F440),
120    (0x1F442, 0x1F4FC),
121    (0x1F4FF, 0x1F53D),
122    (0x1F54B, 0x1F54E),
123    (0x1F550, 0x1F567),
124    (0x1F57A, 0x1F57A),
125    (0x1F595, 0x1F596),
126    (0x1F5A4, 0x1F5A4),
127    (0x1F5FB, 0x1F64F),
128    (0x1F680, 0x1F6C5),
129    (0x1F6CC, 0x1F6CC),
130    (0x1F6D0, 0x1F6D2),
131    (0x1F6D5, 0x1F6D7),
132    (0x1F6EB, 0x1F6EC),
133    (0x1F6F4, 0x1F6FC),
134    (0x1F7E0, 0x1F7EB),
135    (0x1F90C, 0x1F93A),
136    (0x1F93C, 0x1F945),
137    (0x1F947, 0x1F978),
138    (0x1F97A, 0x1F9CB),
139    (0x1F9CD, 0x1F9FF),
140    (0x1FA70, 0x1FA74),
141    (0x1FA78, 0x1FA7A),
142    (0x1FA80, 0x1FA86),
143    (0x1FA90, 0x1FAA8),
144    (0x1FAB0, 0x1FAB6),
145    (0x1FAC0, 0x1FAC2),
146    (0x1FAD0, 0x1FAD6),
147    (0x20000, 0x2FFFD),
148    (0x30000, 0x3FFFD),
149];
150
151/// 한 글자의 표시 폭. 0, 1, 2 중 하나.
152pub fn char_width(c: char) -> usize {
153    let cp = c as u32;
154    if cp == 0 {
155        return 0;
156    }
157    if cp < 0x20 || (0x7F..0xA0).contains(&cp) {
158        // 제어 문자. 표에 들어오면 폭 계산이 무너지므로 0 으로 센다.
159        return 0;
160    }
161    if cp < 0x300 {
162        // 라틴 상용 구간. 표 대부분이 여기라 먼저 끊는다.
163        return 1;
164    }
165    if in_ranges(ZERO, cp) {
166        return 0;
167    }
168    if in_ranges(WIDE, cp) {
169        return 2;
170    }
171    1
172}
173
174/// CJK 인접 강조 정책이 보는 "CJK 문자"인가.
175///
176/// **표시 폭과는 다른 질문이다.** 둘을 한 함수로 쓰면 두 군데가 틀린다 —
177/// 반각 가타카나(`アイウ`)는 폭이 1이지만 CJK 라 패딩이 필요하고, 이모지는 폭이 2지만
178/// CJK 가 아니라 패딩이 필요 없다.
179///
180/// 판정 기준은 CommonMark 의 CJK-friendly 개정안을 따른다 — East Asian Width 가
181/// `W`·`F`·`H` 이면서 이모지 표현이 아니거나, 스크립트가 Hangul 이면 CJK 다.
182/// (<https://github.com/tats-u/markdown-cjk-friendly>)
183pub fn is_cjk(c: char) -> bool {
184    in_ranges(CJK, c as u32)
185}
186
187/// CJK 구간. 한자·가나·한글(조합형 자모 포함)·전각/반각 CJK 기호.
188/// **이모지와 기호는 일부러 뺐다** — 폭이 2여도 CJK 가 아니다.
189const CJK: &[(u32, u32)] = &[
190    (0x1100, 0x11FF), // 한글 자모(조합형). NFD 로 분해된 한글이 여기다
191    (0x2E80, 0x2EF3), // CJK 부수
192    (0x2F00, 0x2FD5), // 강희 부수
193    (0x3000, 0x303F), // CJK 구두점 — `。` `、` `「」` 가 여기다
194    (0x3041, 0x30FF), // 히라가나 · 가타카나
195    (0x3105, 0x312F),
196    (0x3131, 0x318E), // 한글 호환 자모
197    (0x3190, 0x31E3),
198    (0x31F0, 0x321E),
199    (0x3220, 0x3247),
200    (0x3250, 0x4DBF),
201    (0x4E00, 0x9FFF), // 한중일 통합 한자
202    (0xA960, 0xA97C), // 한글 자모 확장 A
203    (0xAC00, 0xD7A3), // 한글 음절
204    (0xD7B0, 0xD7FB), // 한글 자모 확장 B
205    (0xF900, 0xFAFF), // 호환 한자
206    (0xFE10, 0xFE19),
207    (0xFE30, 0xFE6B), // 세로쓰기 형태 · 전각 기호
208    (0xFF01, 0xFF60), // 전각 영숫자·기호 — `()` `,` 가 여기다
209    (0xFF61, 0xFFDC), // **반각** 가타카나·한글. 폭은 1이지만 CJK 다
210    (0xFFE0, 0xFFE6), // 전각 통화 기호 — `¥` `₩`. W/F/H 기준대로 CJK 다
211    (0x20000, 0x2FFFD),
212    (0x30000, 0x3FFFD),
213];
214
215/// 문자열의 표시 폭.
216///
217/// 이모지 결합 연쇄(ZWJ 로 이어진 가족 이모지 등)는 구성 요소를 각각 세므로
218/// 실제 표시보다 넓게 나올 수 있다. 표를 어긋나게 만드는 쪽이 아니라 여유를 주는
219/// 방향이라 v0.1 은 이대로 둔다.
220pub fn str_width(s: &str) -> usize {
221    s.chars().map(char_width).sum()
222}
223
224fn in_ranges(table: &[(u32, u32)], cp: u32) -> bool {
225    table
226        .binary_search_by(|&(lo, hi)| {
227            if cp < lo {
228                std::cmp::Ordering::Greater
229            } else if cp > hi {
230                std::cmp::Ordering::Less
231            } else {
232                std::cmp::Ordering::Equal
233            }
234        })
235        .is_ok()
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    #[test]
243    fn tables_are_sorted_and_disjoint() {
244        // 이진 탐색의 전제. 깨지면 조용히 틀린 폭이 나온다.
245        for table in [ZERO, WIDE] {
246            for w in table.windows(2) {
247                assert!(w[0].1 < w[1].0, "구간이 겹치거나 순서가 틀렸다: {:?}", w);
248            }
249            for &(lo, hi) in table {
250                assert!(lo <= hi);
251            }
252        }
253    }
254
255    #[test]
256    fn known_widths() {
257        for (c, w) in [
258            ('a', 1),
259            ('9', 1),
260            (' ', 1),
261            ('|', 1),
262            ('가', 2),
263            ('힣', 2),
264            ('漢', 2),
265            ('あ', 2),
266            ('ア', 2),
267            (',', 2),
268            (' ', 2), // 전각 공백
269            ('A', 2), // 전각 영문
270            ('✅', 2),
271            ('🚀', 2),
272            ('\u{200b}', 0), // ZWSP
273            ('\u{0301}', 0), // 결합 악센트
274            ('\n', 0),
275        ] {
276            assert_eq!(char_width(c), w, "{c:?} 의 폭이 {w} 가 아니다");
277        }
278    }
279
280    #[test]
281    fn korean_text_is_twice_its_char_count() {
282        let s = "한글";
283        assert_eq!(s.chars().count(), 2);
284        assert_eq!(str_width(s), 4, "문자 수로 맞추면 표가 어긋난다");
285    }
286
287    #[test]
288    fn cjk_is_not_the_same_question_as_width() {
289        // 폭 1인데 CJK — 반각 가타카나. 패딩이 필요하다
290        assert_eq!(char_width('ア'), 1);
291        assert!(is_cjk('ア'));
292        // 폭 2인데 CJK 아님 — 이모지. 패딩이 필요 없다
293        assert_eq!(char_width('🚀'), 2);
294        assert!(!is_cjk('🚀'));
295        assert!(!is_cjk('✅'));
296        // 조합형 한글(NFD). 폭 0인 중성·종성도 CJK 다
297        assert!(is_cjk('\u{1100}') && is_cjk('\u{1161}'));
298        // 전각 통화 기호도 전각(F)이라 CJK 다. 분리하면서 빠뜨리기 쉬운 자리다.
299        for c in ['¥', '₩', '£'] {
300            assert!(is_cjk(c), "{c} 가 CJK 로 안 잡힌다");
301        }
302        // CJK 구두점 — 개정안이 다루는 바로 그 글자들
303        for c in ['。', '、', ',', '「', '」', '(', ')'] {
304            assert!(is_cjk(c), "{c} 가 CJK 로 안 잡힌다");
305        }
306        // 라틴은 아니다
307        for c in ['a', '1', ' ', '.', '-'] {
308            assert!(!is_cjk(c), "{c} 가 CJK 로 잡힌다");
309        }
310    }
311
312    #[test]
313    fn cjk_table_is_sorted_and_disjoint() {
314        for w in CJK.windows(2) {
315            assert!(w[0].1 < w[1].0, "구간이 겹치거나 순서가 틀렸다: {:?}", w);
316        }
317    }
318
319    #[test]
320    fn mixed_width_adds_up() {
321        assert_eq!(str_width("환경 env"), 2 + 2 + 1 + 3);
322    }
323}