diff --git a/.changepacks/changepack_log_MYPKUrMhUnJ3ZY5uU23ET.json b/.changepacks/changepack_log_MYPKUrMhUnJ3ZY5uU23ET.json new file mode 100644 index 00000000..63b952b0 --- /dev/null +++ b/.changepacks/changepack_log_MYPKUrMhUnJ3ZY5uU23ET.json @@ -0,0 +1 @@ +{"changes":{"libs/braillify/Cargo.toml":"Patch","packages/c/Cargo.toml":"Patch","packages/dotnet/Braillify/Braillify.csproj":"Patch","packages/dotnet/BraillifyNet/BraillifyNet.csproj":"Patch","packages/node/package.json":"Patch","packages/python/pyproject.toml":"Patch"},"note":"Rule 35: a unit followed by a hyphenated number stays in one Roman section","date":"2026-09-14T05:16:12.0000000Z"} \ No newline at end of file diff --git a/.changepacks/changepack_log_Qv7bXe2LmTsdRk9pAoZuN.json b/.changepacks/changepack_log_Qv7bXe2LmTsdRk9pAoZuN.json new file mode 100644 index 00000000..699309b7 --- /dev/null +++ b/.changepacks/changepack_log_Qv7bXe2LmTsdRk9pAoZuN.json @@ -0,0 +1 @@ +{"changes":{"libs/braillify/Cargo.toml":"Patch","packages/c/Cargo.toml":"Patch","packages/dotnet/Braillify/Braillify.csproj":"Patch","packages/dotnet/BraillifyNet/BraillifyNet.csproj":"Patch","packages/node/package.json":"Patch","packages/python/pyproject.toml":"Patch"},"note":"Article 37 addendum: an in that follows a hyphen keeps its contraction; corpus accuracy 455,573 of 467,121 sentences.","date":"2026-09-16T10:40:00.0000000Z"} \ No newline at end of file diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a518801f..cdd07407 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -310,6 +310,14 @@ jobs: - jvm-test steps: - uses: actions/checkout@v7 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + # changepacks 는 base branch 와 견주어 바뀐 manifest 를 찾으므로 전체 + # 이력이 필요하다. 기본 shallow checkout 이면 그 비교가 실패한다. + fetch-depth: 0 + # actions/checkout v7 은 pull_request_target 에서 fork PR 코드 체크아웃을 + # 기본 차단한다. 이 opt-in 이 없으면 fork PR 의 모든 job 이 checkout 에서 실패한다. + allow-unsafe-pr-checkout: true - uses: changepacks/action@main id: changepacks with: diff --git a/libs/braillify/AGENTS.md b/libs/braillify/AGENTS.md index 0ea299ed..59a5b9c7 100644 --- a/libs/braillify/AGENTS.md +++ b/libs/braillify/AGENTS.md @@ -238,7 +238,7 @@ bun test test_cases/ # JSON integrity checks (packages/node/pkg 한국어-한국점자 병렬 말뭉치 46만 7121문장이 들어 있다. 말뭉치는 `rule_map.json` 에서 `benchmark: true` 로 표시되어 **pass/fail 에 들어가지 않고 정확도만 보고**한다. -**Current status: 규정 fixture 5141/5141 (100%), 말뭉치 455,395/467,121 (97.49%).** +**Current status: 규정 fixture 5141/5141 (100%), 말뭉치 455,573/467,121 (97.53%).** `KNOWN_FAILURES` 상수는 더 이상 존재하지 않는다. raw `encode()` 가 모든 testcase 에서 PDF 정답과 byte-동일 결과를 낸다. 새로 추가되는 testcase 도 같은 기준을 만족해야 한다. diff --git a/libs/braillify/src/english_logic.rs b/libs/braillify/src/english_logic.rs index 0cf06388..30b4b242 100644 --- a/libs/braillify/src/english_logic.rs +++ b/libs/braillify/src/english_logic.rs @@ -427,9 +427,15 @@ pub(crate) fn should_render_symbol_as_english( match symbol { '(' => { + // 닫는 따옴표는 로마자 구간을 닫으므로 그 뒤의 여는 괄호는 한글에 바로 + // 붙은 괄호(`모터보트(`, `씨넷(`)와 같은 자리다. 제39항 영어 주도 + // 문서에서는 따옴표가 영어 구문 안에 있으므로 이 판정을 적용하지 않는다. + let after_closing_quote = !is_english_majority + && prev_char.is_some_and(|ch| matches!(ch, '\u{2019}' | '\u{201d}')); (is_english_majority || !closed_parenthesis_is_korean_punctuation(word_chars, index, remaining_words)) && is_ascii_letter_or_digit(next_char) + && !after_closing_quote && (!prev_char.is_some_and(utils::is_korean_char) || closed_parenthesis_continues_into_roman(word_chars, index)) } @@ -438,7 +444,17 @@ pub(crate) fn should_render_symbol_as_english( // grade-1 mode in attached Roman forms such as AT&T and B&B. Use // a complete ASCII-letter run so spaced prose, Hangul, and outer // alphanumeric continuations keep their existing routes. - '&' => is_attached_ascii_roman_ampersand(word_chars, index), + // 로마자에 붙은 `&` 는 제29항 구간 안에 남는다. 뒤에 한글이 이어지더라도 + // (`tv&이지사커`) 구간은 그 한글에서 닫히므로, `&` 앞에서 종료표를 적고 + // 다시 여는 일이 없다. + '&' => { + is_attached_ascii_roman_ampersand(word_chars, index) + || (is_english + && prev_char.is_some_and(|ch| ch.is_ascii_alphabetic()) + && word_chars + .get(index + 1) + .is_some_and(|ch| utils::is_korean_char(*ch))) + } // UEB 3.3.1 explicitly keeps the general-purpose asterisk inside the // attached Roman example `M*A*S*H`. Preserve that one Roman section; // Korean Rule 60 continues to own standalone and non-Roman asterisks. @@ -533,6 +549,16 @@ pub(crate) fn should_render_symbol_as_english( let prev_ascii = prev_ascii_letter_or_digit(word_chars, index); let next_ascii = next_ascii_letter_or_digit(word_chars, index, remaining_words); + // 제33항 [다만] — 앞에 로마자가 없이 숫자만 온 빗금은 단위를 가르는 + // 기호이지 제74항 디지털 표기의 일부가 아니다(`17.1/km`). 구간을 열지 + // 않으므로 로마자표가 붙지 않고, 뒤의 로마자가 제 구간을 연다. + if symbol == '/' + && !is_english + && !word_chars[..index].iter().any(char::is_ascii_alphabetic) + { + return false; + } + (prev_ascii && next_ascii) // Korean rules 29/32/35: `Alpha : Beta` is one Roman // section. The print tokenizer makes the colon a standalone @@ -687,6 +713,9 @@ mod tests { false )] #[case::rule_39_english_majority("(Korean:", 0, &["반찬)"], true, true, true, true)] + // 닫는 따옴표 뒤는 제56항의 한국어 괄호, 제39항 문서에서만 UEB 괄호. + #[case::after_closing_quote("’(Motor", 1, &[], true, true, false, false)] + #[case::after_closing_quote_english_majority("’(Motor", 1, &[], true, true, true, true)] fn should_render_symbol_as_english_for_opening_parenthesis( #[case] input: &str, #[case] index: usize, @@ -847,7 +876,9 @@ mod tests { #[case::official_b_and_b("B&B", true, true)] #[case::spaced("A & B", true, false)] #[case::hangul_left("가&B", true, false)] - #[case::hangul_right("A&나", true, false)] + // 제29항 — 로마자 뒤의 `&` 는 한글이 이어져도 구간 안에 남고, 구간은 그 한글에서 + // 닫힌다. 한글이 앞서면(`가&B`) 구간이 열려 있지 않으므로 그대로 거짓이다. + #[case::hangul_right("A&나", true, true)] #[case::digit_neighbor("3&B", true, false)] #[case::digit_outer_left("3A&B", true, false)] #[case::rule35_digit_suffix("A&B3", true, true)] @@ -1041,6 +1072,25 @@ mod symbol_route_coverage { #[cfg(test)] mod digital_notation_coverage { + /// 제33항 [다만] — 앞에 로마자가 없이 숫자만 온 빗금은 로마자 구간을 열지 않는다. + /// 뒤의 로마자가 제 구간을 열고, 빗금 자체는 제33항의 점형으로 적는다. 왼쪽에 + /// 로마자가 있으면(`www.a.kr`, `A/B`) 종전대로 한 구간 안에 남는다. + #[rstest::rstest] + #[case::digits_then_unit("가나 17.1/km, 다라", "⠼⠁⠛⠲⠁⠸⠌⠴⠅⠍⠐")] + #[case::digit_groups("가나 16/32/64GB 다라", "⠼⠁⠋⠸⠌⠼⠉⠃⠸⠌⠼⠋⠙⠴⠠⠠⠛⠃⠲")] + #[case::roman_on_the_left("가나 A/B 다라", "⠴⠠⠁⠸⠌⠠⠃⠲")] + #[case::web_address("가나 www.a.kr 다라", "⠴⠺⠺⠺⠲⠁⠲⠅⠗⠲")] + fn a_slash_after_digits_opens_no_roman_section( + #[case] input: &str, + #[case] expected_segment: &str, + ) { + let actual = crate::encode_to_unicode(input).unwrap(); + assert!( + actual.contains(expected_segment), + "missing slash run {expected_segment:?} in {actual:?}" + ); + } + /// 제74항: 주소 표기는 한 로마자 구간이다. 뒤에 더 이어질 글자가 없으면 그 /// 구분 기호는 일반 기호 경로로 판정한다. #[rstest::rstest] diff --git a/libs/braillify/src/rules/english_ueb/compound.rs b/libs/braillify/src/rules/english_ueb/compound.rs index 5878fa2e..550d9f2b 100644 --- a/libs/braillify/src/rules/english_ueb/compound.rs +++ b/libs/braillify/src/rules/english_ueb/compound.rs @@ -147,10 +147,17 @@ fn parse_compound_line(line: &'static str) -> Option<(&'static str, Vec)> /// table (CompoundPiece + [`SUPPLEMENTAL`]) is authoritative; only when a word is /// absent there do we fall back to the productive [`combining_form_seam`] rule. pub fn compound_seams(word: &str) -> Vec { - if let Some(seams) = SEAMS.get(word) { - return seams.clone(); - } - combining_form_seam(word).into_iter().collect() + let raw = if let Some(seams) = SEAMS.get(word) { + seams.clone() + } else { + combining_form_seam(word).into_iter().collect() + }; + // 합성어의 구성요소는 한 글자일 수 없다. 첫 글자 뒤나 끝 글자 앞의 이음매는 + // 실제 경계가 아니라 표의 잡음이므로 버린다(`w|hole`). + let len = word.chars().count(); + raw.into_iter() + .filter(|seam| *seam >= 2 && *seam + 2 <= len) + .collect() } #[cfg(test)] @@ -176,6 +183,20 @@ mod tests { assert_eq!(compound_seams(std::hint::black_box("anthill")), vec![3]); } + /// 한 글자짜리 구성요소는 없으므로 첫 글자 뒤나 끝 글자 앞의 이음매는 버린다. + /// 그 잡음이 남아 있으면 §10.11.1 이 `wh` 를 경계를 넘는 것으로 보아 없앤다. + #[rstest::rstest] + #[case::whole("whole")] + #[case::wholesale("wholesale")] + #[case::wholegrain("wholegrain")] + fn no_seam_after_the_first_letter(#[case] word: &str) { + assert!( + !compound_seams(word).contains(&1), + "{word} must not expose a one-letter component, got {:?}", + compound_seams(word) + ); + } + /// Coincidental letter splits that are NOT compounds — and CompoundPiece's bogus /// `SEAM_DENYLIST` entries — are absent → no seam, so the contraction is never /// suppressed. diff --git a/libs/braillify/src/rules/korean/rule_28.rs b/libs/braillify/src/rules/korean/rule_28.rs index 0b3cc841..920b9b06 100644 --- a/libs/braillify/src/rules/korean/rule_28.rs +++ b/libs/braillify/src/rules/korean/rule_28.rs @@ -223,9 +223,24 @@ impl BrailleRule for Rule28 { lower_run.as_str(), "be" | "enough" | "his" | "in" | "was" | "were" ); + // 국립국어원 회신(2026-09-15): 붙임표 뒤에 이어진 낱말은 독립적이므로 + // 약자를 쓸 수 있고, 제37항 붙임의 여섯 낱말 중에서는 `in` 만 + // 해당한다 — ⠔ 가 낱말표이면서 UEB §10.6 묶음약자이기 때문이다. + let follows_hyphen = ctx + .index + .checked_sub(1) + .and_then(|index| ctx.word_chars.get(index)) + .is_some_and(|previous| { + matches!( + previous, + '-' | '\u{2010}' | '\u{2011}' | '\u{2012}' | '\u{2013}' | '\u{2014}' + ) + }); + let hyphen_joined_in = lower_run == "in" && follows_hyphen; let rule_37_korean_context_exception = !ctx.state.english_dominant_wrap_active && !ctx.state.roman_section_is_english_context - && is_lower_wordsign; + && is_lower_wordsign + && !hyphen_joined_in; // UEB 10.5 gives lower wordsigns a stricter boundary than ordinary // standing-alone wordsigns. In particular, a hyphen, dash, quote, // or lower punctuation cell touching either side forces spelling. @@ -243,6 +258,7 @@ impl BrailleRule for Rule28 { .copied() .map(EnglishToken::Symbol); let lower_wordsign_boundary_permits = !is_lower_wordsign + || hyphen_joined_in || lower_wordsign_usable(previous_boundary.as_ref(), next_boundary.as_ref()); let standalone_wordsign = is_standing_alone_ordinary_run && (wrap_wordsign || continuing_roman_section) @@ -705,8 +721,8 @@ mod tests { ); } - /// UEB 10.5: a lower wordsign touching a hyphen is not usable even when the - /// surrounding Roman section is clearly an English title. + /// UEB 10.5: a lower wordsign followed by a hyphen is not usable even when + /// the surrounding Roman section is clearly an English title. #[test] fn english_phrase_spells_lower_wordsign_touching_hyphen() { let actual = encode_to_unicode("제목(Alpha In-house Teams)이다.") @@ -722,6 +738,42 @@ mod tests { ); } + /// 국립국어원 회신(2026-09-15): 붙임표 뒤에 이어진 `in` 은 독립적이라 약자를 + /// 쓴다. 제37항 붙임의 나머지 다섯 낱말은 해당하지 않는다. + #[rstest::rstest] + #[case::between_hyphens("클라우드(Cloud-in-a-Box)라는", "⠤⠔⠤")] + #[case::before_closing_paren("록인(lock-in)을", "⠤⠔⠠⠴")] + #[case::before_opening_paren("‘built-in(빌트인)’", "⠤⠔⠦⠄")] + #[case::capitalized("팬인(Fan-In)", "⠤⠠⠔⠠⠴")] + fn hyphen_joined_in_keeps_its_contraction(#[case] input: &str, #[case] expected: &str) { + let actual = encode_to_unicode(input).expect("hyphenated `in` must encode"); + + assert!( + actual.contains(expected), + "`in` after a hyphen must contract to ⠔: {actual}" + ); + } + + /// 제37항 붙임 과 UEB 10.5 는 그대로다 — 붙임표가 앞에 없는 나머지 다섯 + /// 낱말은 여전히 철자로 적는다. + #[rstest::rstest] + #[case::whole_roman_item("가나 in 다라", "⠴⠊⠝⠲")] + #[case::hyphen_only_follows("내부(in-house)에서", "⠴⠊⠝⠤")] + #[case::hyphen_joined_was("클라우드(Cloud-was-a-Box)라는", "⠤⠺⠁⠎⠤")] + #[case::hyphen_joined_his("클라우드(Cloud-his-a-Box)라는", "⠤⠓⠊⠎⠤")] + #[case::hyphen_joined_be("클라우드(Cloud-be-a-Box)라는", "⠤⠃⠑⠤")] + fn hyphen_rule_leaves_the_other_lower_wordsigns_spelled( + #[case] input: &str, + #[case] expected: &str, + ) { + let actual = encode_to_unicode(input).expect("lower wordsign context must encode"); + + assert!( + actual.contains(expected), + "lower wordsign must stay spelled: {actual}" + ); + } + #[test] fn apply_skips_non_korean() { let mut owned = crate::test_helpers::CtxOwned::for_text("A", false); diff --git a/libs/braillify/src/rules/korean/rule_69.rs b/libs/braillify/src/rules/korean/rule_69.rs index 394736f3..01e7a6bd 100644 --- a/libs/braillify/src/rules/korean/rule_69.rs +++ b/libs/braillify/src/rules/korean/rule_69.rs @@ -642,10 +642,9 @@ fn omit_roman_terminator_before_boundary( && word .get(boundary_index + 1) .is_some_and(|next| is_roman_unit_component(*next)); - let continues_into_number = word - .get(boundary_index) - .is_some_and(|next| next.is_ascii_digit()); - if (skips_for_punctuation || continues_through_slash || continues_into_number) + if (skips_for_punctuation + || continues_through_slash + || unit_continues_into_number(word, boundary_index)) && encoded.last() == Some(&crate::unicode::decode_unicode('⠲')) { encoded.pop(); @@ -694,6 +693,18 @@ fn roman_unit_continues_into_next_word(ctx: &RuleContext, boundary_index: usize) .is_some_and(|ch| ch.is_ascii_alphanumeric()) } +/// 제35항 `D-100` 은 붙임표로 이어지는 숫자를 같은 로마자 구간에 둔다. 단위 뒤도 +/// 같은 자리여서(`799cc-7`, `25kg-3`) 구간이 닫히지 않는다. +fn unit_continues_into_number(word: &[char], boundary_index: usize) -> bool { + word.get(boundary_index).is_some_and(|next| { + next.is_ascii_digit() + || (*next == '-' + && word + .get(boundary_index + 1) + .is_some_and(char::is_ascii_digit)) + }) +} + fn omit_trailing_roman_terminator(encoded: &mut Vec) { if encoded.last() == Some(&crate::unicode::decode_unicode('⠲')) { encoded.pop(); @@ -725,7 +736,9 @@ pub(crate) fn adjust_roman_unit_boundary( if separated_continues { omit_trailing_roman_terminator(encoded); } - comma_continues || separated_continues + comma_continues + || separated_continues + || unit_continues_into_number(ctx.word_chars, boundary_index) } fn should_insert_separator_after_symbol(symbol: char, next: Option) -> bool { @@ -1466,6 +1479,23 @@ mod tests { ); } + /// 제35항 — 단위 뒤에서 붙임표로 이어지는 숫자는 종료표도 로마자표도 없이 같은 + /// 구간에 남는다. 붙임표 뒤가 숫자가 아니면 평소대로 구간을 닫는다. + #[rstest::rstest] + #[case::volume_unit("가나 799cc-7 다라", "⠴⠉⠉⠤⠼⠛")] + #[case::mass_unit("가나 25kg-3 다라", "⠴⠅⠛⠤⠼⠉")] + #[case::hyphen_before_letter("가나 25kg-a 다라", "⠴⠅⠛⠲")] + fn unit_hyphen_number_stays_in_one_roman_section( + #[case] input: &str, + #[case] expected_segment: &str, + ) { + let actual = crate::encode_to_unicode(input).unwrap(); + assert!( + actual.contains(expected_segment), + "missing rule-35 hyphen chain {expected_segment:?} in {actual:?}" + ); + } + #[test] fn boundary_helper_does_not_remove_non_terminator_cells() { let word = "kg)".chars().collect::>(); diff --git a/libs/braillify/src/rules/korean/rule_71.rs b/libs/braillify/src/rules/korean/rule_71.rs index d6ebf3f5..84025c79 100644 --- a/libs/braillify/src/rules/korean/rule_71.rs +++ b/libs/braillify/src/rules/korean/rule_71.rs @@ -63,7 +63,7 @@ fn should_wrap_information_symbol(ctx: &RuleContext) -> bool { /// to a Roman word (`Jeep®`) sits inside the open 제29항 section, where UEB /// 3.1 reads its own cells and no re-entry indicator is needed. fn follows_roman_word_in_open_section(ctx: &RuleContext) -> bool { - matches!(ctx.current_char(), '®' | '™') + matches!(ctx.current_char(), '®' | '™' | '&') && ctx.state.is_english && ctx.prev_char().is_some_and(|ch| ch.is_ascii_alphanumeric()) } @@ -237,6 +237,24 @@ mod tests { assert_eq!(ctx.result.as_slice(), encode_unicode_cells("⠈⠯")); } + /// 제29항 — 로마자에 붙은 `&` 는 뒤에 한글이 이어져도 구간 안에 남는다. 구간은 + /// 그 한글에서 닫히므로 `&` 앞에 종료표가 서지 않는다. 공식 예 `AT&T` 와 한글에 + /// 닿지 않는 `A&B` 는 그대로다. + #[rstest::rstest] + #[case::korean_follows("가나 쏠로몬tv&이지사커 다라", "⠴⠞⠧⠈⠯⠲⠕")] + #[case::official_at_and_t("가나 AT&T 다라", "⠴⠠⠠⠁⠞⠈⠯⠠⠞⠲")] + #[case::roman_both_sides("가나 A&B 다라", "⠴⠠⠁⠈⠯⠠⠃⠲")] + fn attached_ampersand_keeps_the_open_roman_section( + #[case] input: &str, + #[case] expected_segment: &str, + ) { + let actual = crate::encode_to_unicode(input).unwrap(); + assert!( + actual.contains(expected_segment), + "missing ampersand run {expected_segment:?} in {actual:?}" + ); + } + /// UEB 3.1.1's official `&c` surface exercises the Korean Rule-71 wrapper /// state directly: the Roman indicator precedes `&`, and the section stays /// open for the attached `c` rather than emitting a terminator/re-entry. diff --git a/libs/braillify/src/rules/korean/rule_english_symbol.rs b/libs/braillify/src/rules/korean/rule_english_symbol.rs index ca84b84e..beb7115d 100644 --- a/libs/braillify/src/rules/korean/rule_english_symbol.rs +++ b/libs/braillify/src/rules/korean/rule_english_symbol.rs @@ -164,6 +164,14 @@ impl BrailleRule for RuleEnglishSymbol { } if *sym == '(' { + // 닫는 따옴표 뒤의 한국어 괄호는 앞 구간을 완전히 닫는다. 예약된 연속표 + // ⠰ 를 지워 괄호 안 로마자가 제29항의 로마자표 ⠴ 로 새로 열리게 한다. + if !use_english_symbol + && ctx.index > 0 + && matches!(ctx.word_chars[ctx.index - 1], '\u{2019}' | '\u{201d}') + { + crate::rules::roman_mode::clear_pending_continuation(ctx.state); + } ctx.state.parenthesis_stack.push(use_english_symbol); } else if *sym == ')' { use_english_symbol = ctx @@ -372,6 +380,23 @@ mod tests { assert!(!ctx.state.parenthesis_stack.is_empty()); } + /// 닫는 따옴표 뒤의 괄호는 제56항의 한국어 괄호 ⠦⠄ 이고, 그 안의 로마자는 + /// 예약된 연속표 ⠰ 가 아니라 제29항의 로마자표 ⠴ 로 새로 열린다. 한글에 바로 + /// 붙은 괄호(`모터보트(`)와 같은 자리다. + #[rstest::rstest] + #[case::after_closing_quote("가나 ‘MDPS’(Motor 다라", "⠴⠄⠦⠄⠴⠠⠍⠕⠞⠕⠗")] + #[case::attached_to_korean("가나 모터보트(Motor 다라", "⠦⠄⠴⠠⠍⠕⠞⠕⠗")] + fn closing_quote_opens_a_fresh_roman_section( + #[case] input: &str, + #[case] expected_segment: &str, + ) { + let actual = crate::encode_to_unicode(input).unwrap(); + assert!( + actual.contains(expected_segment), + "missing Korean parenthesis run {expected_segment:?} in {actual:?}" + ); + } + #[test] fn closing_parenthesis_reuses_opening_parenthesis_symbol_mode() { let mut owned = crate::test_helpers::CtxOwned::for_text("()", true);