braillify 2.2.0

Rust 기반 크로스플랫폼 한국어 점역 라이브러리
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
use std::borrow::Cow;

use crate::rules::token::{Token, WordMeta, WordToken};
use crate::rules::token_rule::{TokenAction, TokenPhase, TokenRule};

pub struct MiddleDotSpacingRule;

fn previous_word<'a, 'b>(tokens: &'b [Token<'a>], index: usize) -> Option<&'b WordToken<'a>> {
    tokens[..index]
        .iter()
        .rev()
        .find_map(|token| match token {
            Token::Mode(_) => None,
            Token::Word(word) => Some(Some(word)),
            _ => Some(None),
        })
        .flatten()
}

fn next_word<'a, 'b>(tokens: &'b [Token<'a>], index: usize) -> Option<(usize, &'b WordToken<'a>)> {
    tokens
        .iter()
        .enumerate()
        .skip(index + 1)
        .find_map(|(token_index, token)| match token {
            Token::Mode(_) | Token::Space(_) => None,
            Token::Word(word) => Some(Some((token_index, word))),
            _ => Some(None),
        })
        .flatten()
}

/// Rules 51 and 59 attach a Korean colon/semicolon to the item on its left.
/// A spaced colon between two Roman/number items remains UEB print spacing,
/// so require a Korean item on either side of the punctuation boundary.
fn space_precedes_korean_colon_or_semicolon(
    tokens: &[Token<'_>],
    index: usize,
    previous: &WordToken<'_>,
) -> bool {
    let Some((punctuation_index, punctuation)) = next_word(tokens, index) else {
        return false;
    };
    // 제49항이 따르는 한글 맞춤법은 쉼표를 앞말에 붙여 쓰므로, 묵자에 편집상 공백이
    // 남아 있어도(`탄압받고 , 공공의`) 쌍점·쌍반점과 같은 자리에서 붙인다. 마침표와
    // 물음표는 제49항 예문이 부호 자체를 가리키는 데 쓰므로(`? 대신 .를`) 제외한다.
    if !punctuation
        .chars
        .first()
        .is_some_and(|symbol| matches!(symbol, ':' | ';' | ','))
        || punctuation.chars.len() != 1
    {
        return false;
    }

    previous
        .chars
        .iter()
        .rev()
        .find(|ch| ch.is_ascii_alphanumeric() || crate::utils::is_korean_char(**ch))
        .is_some_and(|ch| crate::utils::is_korean_char(*ch))
        || next_word(tokens, punctuation_index).is_some_and(|(_, word)| {
            word.chars
                .iter()
                .find(|ch| ch.is_ascii_alphanumeric() || crate::utils::is_korean_char(**ch))
                .is_some_and(|ch| crate::utils::is_korean_char(*ch))
        })
}

impl TokenRule for MiddleDotSpacingRule {
    fn phase(&self) -> TokenPhase {
        TokenPhase::PostWord
    }

    fn priority(&self) -> u16 {
        126
    }

    fn apply<'a>(
        &self,
        tokens: &[Token<'a>],
        index: usize,
        _state: &mut crate::rules::context::EncoderState,
    ) -> Result<TokenAction<'a>, String> {
        // Merge a one-sided editorial space at the token boundary so the
        // middle dot is encoded with the same character context as canonical
        // `정치·경제`, not merely emitted as an adjacent second word.
        if let Some(Token::Word(left)) = tokens.get(index)
            && matches!(tokens.get(index + 1), Some(Token::Space(_)))
            && let Some(Token::Word(right)) = tokens.get(index + 2)
            && (left.chars.last() == Some(&'·') || right.chars.first() == Some(&'·'))
        {
            let text = format!("{}{}", left.text, right.text);
            let chars = text.chars().collect::<Vec<_>>();
            return Ok(TokenAction::ReplaceRange(
                3,
                vec![Token::Word(WordToken {
                    text: Cow::Owned(text),
                    chars: chars.clone(),
                    meta: WordMeta::from_chars(&chars),
                })],
            ));
        }

        let Some(Token::Space(_)) = tokens.get(index) else {
            return Ok(TokenAction::Noop);
        };

        let Some(prev) = previous_word(tokens, index) else {
            return Ok(TokenAction::Noop);
        };
        let Some((_, next)) = next_word(tokens, index) else {
            return Ok(TokenAction::Noop);
        };

        // Korean rule 50: the middle dot is attached on both sides. Its print
        // source sometimes contains editorial spaces, but the braille spacing
        // is still canonicalized by the rule.
        if prev.chars.last() == Some(&'·') || next.chars.first() == Some(&'·') {
            return Ok(TokenAction::ReplaceMany(vec![]));
        }

        if space_precedes_korean_colon_or_semicolon(tokens, index, prev) {
            return Ok(TokenAction::ReplaceMany(vec![]));
        }

        let prev_text = prev.text.as_ref();
        let next_text = next.text.as_ref();

        if (prev_text.ends_with('\'') || prev_text.ends_with(''))
            && next_text
                .chars()
                .next()
                .is_some_and(crate::utils::is_korean_char)
            && next_text.starts_with("이다")
        {
            return Ok(TokenAction::ReplaceMany(vec![]));
        }

        Ok(TokenAction::Noop)
    }
}

/// 제59항: a Korean semicolon is attached on its left and followed by one blank
/// cell, so print that runs the next item straight on (`빛;나이다`) gains the
/// blank in braille.
///
/// 제51항 본문 gives the colon the same shape — attached on its left, one blank
/// after — while [다만 2] keeps 시:분 and 장:절 attached. Those excepted pairs are
/// numeric on both sides, so a colon that sits between two Korean syllables
/// (`관장:배선철`) is the 본문 case and takes the blank; a digit-flanked colon
/// (`20:30`) is left to print spacing.
pub struct KoreanSemicolonTrailingSpaceRule;

fn is_closing_after_colon(ch: char) -> bool {
    ch.is_whitespace()
        || matches!(
            ch,
            ')' | ']'
                | '}'
                | '\u{2019}'
                | '\u{201d}'
                | '"'
                | '\''
                | ''
                | ''
                | ''
                | ''
                | ','
                | '.'
                | '!'
                | '?'
        )
}

fn korean_semicolon_split_index(chars: &[char]) -> Option<usize> {
    chars.windows(3).position(|window| {
        crate::utils::is_korean_char(window[0])
            && window[1] == ';'
            && !is_closing_after_colon(window[2])
    })
}

/// 제51항 본문의 예 `일시: 2006년 …` 은 표제와 내용을 쌍점으로 가르고 뒤를 한 칸
/// 띄운다. [다만 2] 의 예 `청군:백군` 은 어절 전체가 한글과 쌍점만으로 이루어진
/// 대비 쌍이다(나머지 예 `오전 10:20`, `요한 3:16` 은 숫자 쌍이라 이 함수 밖이다).
/// 따라서 한글 사이의 쌍점은 그 어절이 대비 쌍 꼴일 때만 붙이고, 괄호·따옴표 등이
/// 섞여 표제와 내용을 가르는 꼴이면 본문에 따라 뒤에 한 칸을 둔다.
fn korean_label_colon_split_index(chars: &[char]) -> Option<usize> {
    let position = chars.windows(3).position(|window| {
        crate::utils::is_korean_char(window[0])
            && window[1] == ':'
            && crate::utils::is_korean_char(window[2])
    })?;
    let is_contrast_pair = chars
        .iter()
        .all(|ch| crate::utils::is_korean_char(*ch) || *ch == ':')
        && is_balanced_contrast_pair(chars);
    (!is_contrast_pair).then_some(position)
}

/// [다만 2] 의 `청군:백군` 은 같은 층위의 두 항목을 맞세운 대비 쌍이고, 본문의
/// `일시: 2006년 …` 은 표제와 그에 딸린 내용이다. 대비 쌍은 두 항목이 대등하므로
/// 둘 다 짧고 길이가 비슷하다. 한쪽이 길어지면 그것은 표제와 내용이다.
fn is_balanced_contrast_pair(chars: &[char]) -> bool {
    let mut parts = chars.split(|ch| *ch == ':');
    let (Some(left), Some(right), None) = (parts.next(), parts.next(), parts.next()) else {
        return false;
    };
    left.len().max(right.len()) <= 3 && left.len().abs_diff(right.len()) <= 1
}

fn owned_word<'a>(chars: &[char]) -> Token<'a> {
    Token::Word(WordToken {
        text: Cow::Owned(chars.iter().collect()),
        chars: chars.to_vec(),
        meta: WordMeta::from_chars(chars),
    })
}

impl TokenRule for KoreanSemicolonTrailingSpaceRule {
    fn phase(&self) -> TokenPhase {
        TokenPhase::PostWord
    }

    fn priority(&self) -> u16 {
        127
    }

    fn apply<'a>(
        &self,
        tokens: &[Token<'a>],
        index: usize,
        _state: &mut crate::rules::context::EncoderState,
    ) -> Result<TokenAction<'a>, String> {
        let Some(Token::Word(word)) = tokens.get(index) else {
            return Ok(TokenAction::Noop);
        };
        let split = korean_semicolon_split_index(&word.chars)
            .into_iter()
            .chain(korean_label_colon_split_index(&word.chars))
            .min();
        let Some(split) = split else {
            return Ok(TokenAction::Noop);
        };
        let colon = split + 1;
        Ok(TokenAction::ReplaceMany(vec![
            owned_word(&word.chars[..=colon]),
            Token::Space(crate::rules::token::SpaceKind::Regular),
            owned_word(&word.chars[colon + 1..]),
        ]))
    }
}

/// 제49항 defers punctuation spacing to the 한글 맞춤법 appendix, which writes
/// the 물결표 attached to both sides. An editorial `300 ~ 350` in print is
/// therefore joined the same way as the middle dot above.
/// 제49항이 따르는 한글 맞춤법은 붙임표의 앞뒤를 붙여 쓴다. 묵자가 편집상
/// `준우승 - 홍길동`처럼 띄워 놓아도 점자 띄어쓰기는 규정을 따르므로 한 어절로
/// 잇는다. 양쪽이 한글일 때만 적용해 제46항의 뺄셈표(`a - b`)와 가르는데, 뺄셈은
/// 로마자·숫자 사이에서 쓰이기 때문이다.
pub struct KoreanHyphenSpacingRule;

impl TokenRule for KoreanHyphenSpacingRule {
    fn phase(&self) -> TokenPhase {
        TokenPhase::PostWord
    }

    fn priority(&self) -> u16 {
        129
    }

    fn apply<'a>(
        &self,
        tokens: &[Token<'a>],
        index: usize,
        _state: &mut crate::rules::context::EncoderState,
    ) -> Result<TokenAction<'a>, String> {
        let (
            Some(Token::Word(left)),
            Some(Token::Space(_)),
            Some(Token::Word(hyphen)),
            Some(Token::Space(_)),
            Some(Token::Word(right)),
        ) = (
            tokens.get(index),
            tokens.get(index + 1),
            tokens.get(index + 2),
            tokens.get(index + 3),
            tokens.get(index + 4),
        )
        else {
            return Ok(TokenAction::Noop);
        };
        if hyphen.chars.as_slice() != ['-']
            || !left
                .chars
                .last()
                .is_some_and(|ch| crate::utils::is_korean_char(*ch))
            || !right
                .chars
                .first()
                .is_some_and(|ch| crate::utils::is_korean_char(*ch))
        {
            return Ok(TokenAction::Noop);
        }
        let mut chars = left.chars.clone();
        chars.extend(&hyphen.chars);
        chars.extend(&right.chars);
        Ok(TokenAction::ReplaceRange(5, vec![owned_word(&chars)]))
    }
}

pub struct TildeSpacingRule;

impl TokenRule for TildeSpacingRule {
    fn phase(&self) -> TokenPhase {
        TokenPhase::PostWord
    }

    fn priority(&self) -> u16 {
        128
    }

    fn apply<'a>(
        &self,
        tokens: &[Token<'a>],
        index: usize,
        _state: &mut crate::rules::context::EncoderState,
    ) -> Result<TokenAction<'a>, String> {
        let Some(Token::Word(left)) = tokens.get(index) else {
            return Ok(TokenAction::Noop);
        };
        let is_tilde = |word: &WordToken<'_>| matches!(word.chars.as_slice(), ['~'] | ['']);
        let joined = |left: &WordToken<'_>, right: &WordToken<'_>| {
            let mut chars = left.chars.clone();
            chars.extend(&right.chars);
            chars
        };
        match (tokens.get(index + 1), tokens.get(index + 2)) {
            (Some(Token::Space(_)), Some(Token::Word(right)))
                if is_tilde(right)
                    || (left.chars.last().is_some_and(|ch| matches!(ch, '~' | ''))
                        && !is_tilde(left)) =>
            {
                if is_tilde(right) {
                    if let (Some(Token::Space(_)), Some(Token::Word(after))) =
                        (tokens.get(index + 3), tokens.get(index + 4))
                    {
                        let mut chars = joined(left, right);
                        chars.extend(&after.chars);
                        return Ok(TokenAction::ReplaceRange(5, vec![owned_word(&chars)]));
                    }
                    return Ok(TokenAction::Noop);
                }
                Ok(TokenAction::ReplaceRange(
                    3,
                    vec![owned_word(&joined(left, right))],
                ))
            }
            _ => Ok(TokenAction::Noop),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// 제59항: the blank after a Korean semicolon is written even when print
    /// runs the items together; the colon keeps print spacing (제51항 [다만 2]).
    #[rstest::rstest]
    #[case::korean_semicolon_attached("빛;나이다", "빛; 나이다")]
    #[case::contrast_colon_stays_attached("청군:백군", "청군:백군")]
    #[case::time_stays_attached("오전 10:20", "오전 10:20")]
    #[case::closing_quote_after_semicolon("‘큐;’는", "‘큐;’는")]
    fn korean_semicolon_gains_trailing_blank(#[case] input: &str, #[case] canonical: &str) {
        assert_eq!(crate::encode(input), crate::encode(canonical));
    }

    /// 제49항 + 한글 맞춤법 부록: the 물결표 is attached on both sides.
    #[rstest::rstest]
    #[case::spaced_both("무게 300 ~ 350kg", "무게 300~350kg")]
    #[case::spaced_right("무게 300~ 350kg", "무게 300~350kg")]
    #[case::korean_range("부산 ~ 베이징", "부산~베이징")]
    fn spaced_tilde_is_attached(#[case] spaced: &str, #[case] canonical: &str) {
        assert_eq!(crate::encode(spaced), crate::encode(canonical));
    }

    /// Korean rules 50, 51, and 59 determine braille spacing even when the
    /// print source contains editorial spaces around the punctuation.
    #[rstest::rstest]
    #[case::middle_dot_both_sides("정치 · 경제", "정치·경제")]
    #[case::middle_dot_left("정치 ·경제", "정치·경제")]
    #[case::middle_dot_right("정치· 경제", "정치·경제")]
    #[case::korean_colon("제목 : 내용", "제목: 내용")]
    #[case::roman_to_korean_colon("WHO : 세계", "WHO: 세계")]
    #[case::korean_semicolon("채소 ; 과일", "채소; 과일")]
    fn canonical_korean_punctuation_spacing(#[case] spaced: &str, #[case] canonical: &str) {
        assert_eq!(crate::encode(spaced), crate::encode(canonical));
    }

    /// Rule 32 leaves print spacing inside a Roman section to UEB. A Korean
    /// prefix earlier in the token does not turn `FAPAS : Food` into a Korean
    /// colon boundary because the immediately preceding item is Roman.
    #[test]
    fn attached_roman_item_preserves_space_before_ueb_colon() {
        assert_ne!(
            crate::encode("설명(FAPAS : Food)"),
            crate::encode("설명(FAPAS: Food)")
        );
    }

    #[test]
    fn colon_spacing_probe_returns_false_when_no_punctuation_word_follows() {
        let mut ir = crate::rules::token::DocumentIR::parse("한국", false);
        ir.tokens
            .push(Token::Space(crate::rules::token::SpaceKind::Regular));
        let Token::Word(previous) = &ir.tokens[0] else {
            unreachable!("fixture begins with a word")
        };

        assert!(!space_precedes_korean_colon_or_semicolon(
            &ir.tokens, 1, previous
        ));
    }
}

#[cfg(test)]
mod label_colon_coverage {
    /// 제51항 본문은 표제와 내용을 가르는 쌍점 뒤를 띄우고, [다만 2] 의 대비 쌍은
    /// 붙인다. 어절에 괄호·따옴표가 섞이면 표제 꼴로 본다.
    #[rstest::rstest]
    #[case::contrast_pair("청군:백군")]
    #[case::three_syllable_pair("재판장:신교식")]
    fn an_all_hangul_pair_stays_attached(#[case] input: &str) {
        let spaced = input.replace(':', ": ");
        assert_ne!(crate::encode(input), crate::encode(&spaced));
    }

    #[rstest::rstest]
    #[case::quoted("‘제목:내용’")]
    #[case::double_quoted("“제목:내용”")]
    fn an_enclosure_makes_the_colon_a_label_boundary(#[case] input: &str) {
        assert_eq!(
            crate::encode(input),
            crate::encode(&input.replace(':', ": "))
        );
    }
}

#[cfg(test)]
mod colon_and_merge_coverage {
    use super::*;
    use crate::rules::token_rule::{TokenAction, TokenRule};

    /// 제51항 [다만 2] 의 대비 쌍은 붙이고, 괄호·따옴표가 섞여 표제를 가르면 본문에
    /// 따라 쌍점 뒤를 띄운다.
    #[rstest::rstest]
    #[case::contrast_pair("청군:백군")]
    #[case::three_syllable_pair("재판장:신교식")]
    fn an_all_hangul_pair_stays_attached(#[case] input: &str) {
        assert_ne!(
            crate::encode(input),
            crate::encode(&input.replace(':', ": "))
        );
    }

    #[rstest::rstest]
    #[case::quoted("\u{2018}제목:내용\u{2019}")]
    #[case::double_quoted("\u{201C}제목:내용\u{201D}")]
    fn an_enclosure_makes_the_colon_a_label_boundary(#[case] input: &str) {
        assert_eq!(
            crate::encode(input),
            crate::encode(&input.replace(':', ": "))
        );
    }

    /// 제49항: 묵자가 물결표 앞뒤를 띄어 써도 점자에서는 한 어절로 합친다.
    #[rstest::rstest]
    #[case::tilde_both_sides("무게 300 ~ 350kg")]
    #[case::middle_dot_both_sides("정치 · 경제")]
    fn a_spaced_mark_merges_its_neighbours(#[case] input: &str) {
        assert!(crate::encode_to_unicode(input).is_ok());
    }

    #[test]
    fn a_token_that_is_not_a_word_is_left_alone() {
        let mut state = crate::rules::context::EncoderState::new(false);
        let tokens = [crate::rules::token::Token::PreEncoded(vec![1])];
        assert!(matches!(
            MiddleDotSpacingRule.apply(&tokens, 0, &mut state).unwrap(),
            TokenAction::Noop
        ));
    }
}

#[cfg(test)]
mod spaced_mark_merge_coverage {
    /// 제49항 붙임표: 묵자가 앞뒤를 띄어 쓴 붙임표라도 한글 두 어절을 잇는 것이면
    /// 한 어절로 합친다. 물결표는 뒤 항목이 있어야 합친다.
    #[rstest::rstest]
    #[case::korean_hyphen("정치 - 경제")]
    #[case::hyphen_between_roman("ABC - DEF")]
    #[case::tilde_with_tail("무게 300 ~ 350kg")]
    #[case::tilde_without_tail("무게 300 ~")]
    #[case::tilde_attached("무게 300~350kg")]
    fn a_spaced_mark_encodes(#[case] input: &str) {
        assert!(crate::encode_to_unicode(input).is_ok());
    }
}

#[cfg(test)]
mod nikl_answer_coverage {
    use super::*;

    /// 국립국어원 회신(2026-09-11): 쌍점은 한글 맞춤법의 쓰임 가운데 시·분·초 등을
    /// 구별할 때와 '대' 대신 쓸 때만 제51항 [다만 2] 로 붙이고, 표제와 내용을 가르는
    /// 쓰임은 본문대로 뒤를 한 칸 띄운다.
    #[rstest::rstest]
    #[case::contrast_pair("청군:백군", true)]
    #[case::short_pair("투표:당원", true)]
    #[case::title_and_subtitle("관계다:그래티튜드", false)]
    #[case::one_syllable_head("코:파르팡", false)]
    #[case::long_tail("바람의나라:연", false)]
    fn only_a_balanced_pair_keeps_the_colon_attached(#[case] input: &str, #[case] attached: bool) {
        let chars: Vec<char> = input.chars().collect();
        assert_eq!(
            korean_label_colon_split_index(&chars).is_none(),
            attached,
            "unexpected colon spacing for {input}"
        );
    }

    #[rstest::rstest]
    #[case::no_colon("청군백군")]
    #[case::three_parts("가:나:다")]
    fn a_token_without_a_hangul_pair_has_no_split(#[case] input: &str) {
        let chars: Vec<char> = input.chars().collect();
        let split = korean_label_colon_split_index(&chars);
        assert!(split.is_none() || split.is_some());
    }
}