From 8d6986405561eb3f2c52350937443692f6a52655 Mon Sep 17 00:00:00 2001 From: devfive Date: Mon, 14 Sep 2026 14:16:12 +0900 Subject: [PATCH 1/8] Keep a unit's hyphenated number in the same Roman section Rule 35 keeps the number of a hyphenated Roman identifier (D-100) inside the open Roman section. The rule 69 unit boundary only recognised a digit sitting directly at the boundary, so 799cc-7 closed the section at the hyphen and reopened it, emitting a terminator and a Roman indicator that the reference does not write. --- .../changepack_log_MYPKUrMhUnJ3ZY5uU23ET.json | 12 ++++++ libs/braillify/AGENTS.md | 2 +- libs/braillify/src/rules/korean/rule_69.rs | 40 ++++++++++++++++--- 3 files changed, 48 insertions(+), 6 deletions(-) create mode 100644 .changepacks/changepack_log_MYPKUrMhUnJ3ZY5uU23ET.json diff --git a/.changepacks/changepack_log_MYPKUrMhUnJ3ZY5uU23ET.json b/.changepacks/changepack_log_MYPKUrMhUnJ3ZY5uU23ET.json new file mode 100644 index 00000000..3ac83c7a --- /dev/null +++ b/.changepacks/changepack_log_MYPKUrMhUnJ3ZY5uU23ET.json @@ -0,0 +1,12 @@ +{ + "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" +} diff --git a/libs/braillify/AGENTS.md b/libs/braillify/AGENTS.md index 0ea299ed..d383b262 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,410/467,121 (97.49%).** `KNOWN_FAILURES` 상수는 더 이상 존재하지 않는다. raw `encode()` 가 모든 testcase 에서 PDF 정답과 byte-동일 결과를 낸다. 새로 추가되는 testcase 도 같은 기준을 만족해야 한다. 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::>(); From 62b0c6bbccbf1fe1a09ddf32aa1328569246218f Mon Sep 17 00:00:00 2001 From: devfive Date: Mon, 14 Sep 2026 16:02:48 +0900 Subject: [PATCH 2/8] Give the changepacks job the PR ref and full history Every other job in this workflow pins the checkout to the PR head; the changepacks job did not, so under pull_request_target it read main at depth 1. changepacks compares manifests against the base branch, which a shallow checkout cannot do. --- .github/workflows/publish.yml | 8 ++++++++ 1 file changed, 8 insertions(+) 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: From e429caee5192c0ac0deaf9abefb02df1313d101f Mon Sep 17 00:00:00 2001 From: devfive Date: Mon, 14 Sep 2026 16:42:56 +0900 Subject: [PATCH 3/8] Keep the Korean parenthesis after a closing quote A closing quote ends the Roman section, so the parenthesis that follows sits where one attached to Hangul does and takes rule 56's Korean form. The corpus writes the Korean parenthesis there 321 times against 1. Clearing the reserved continuation lets the Roman run inside open with rule 29's indicator rather than the continuation sign. --- libs/braillify/AGENTS.md | 2 +- libs/braillify/src/english_logic.rs | 9 +++++++ .../src/rules/korean/rule_english_symbol.rs | 25 +++++++++++++++++++ 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/libs/braillify/AGENTS.md b/libs/braillify/AGENTS.md index d383b262..04d6e9c5 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,410/467,121 (97.49%).** +**Current status: 규정 fixture 5141/5141 (100%), 말뭉치 455,451/467,121 (97.50%).** `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..ac3a7874 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)) } @@ -687,6 +693,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, 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); From 44d387a0a08323a32687adbee73d1e887a06004d Mon Sep 17 00:00:00 2001 From: devfive Date: Mon, 14 Sep 2026 18:42:35 +0900 Subject: [PATCH 4/8] Write the changepack logs without a BOM PowerShell's Set-Content wrote a UTF-8 byte order mark, and changepacks reads the file with a parser that rejects it: 'Failed to parse changepack log ...: expected value at line 1 column 1'. Match the single-line shape the other logs already use. --- .../changepack_log_F7sKFk0VHuuAuj0_bSvZ7.json | 13 +------------ .../changepack_log_MYPKUrMhUnJ3ZY5uU23ET.json | 13 +------------ 2 files changed, 2 insertions(+), 24 deletions(-) diff --git a/.changepacks/changepack_log_F7sKFk0VHuuAuj0_bSvZ7.json b/.changepacks/changepack_log_F7sKFk0VHuuAuj0_bSvZ7.json index c849cf46..2b9c52ee 100644 --- a/.changepacks/changepack_log_F7sKFk0VHuuAuj0_bSvZ7.json +++ b/.changepacks/changepack_log_F7sKFk0VHuuAuj0_bSvZ7.json @@ -1,12 +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": "RUEB 10.7.4: the ever contraction is blocked only by a preceding e or i, not by every vowel", - "date": "2026-09-14T02:42:58.0000000Z" -} +{"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":"RUEB 10.7.4: the ever contraction is blocked only by a preceding e or i, not by every vowel","date":"2026-09-14T02:42:58.0000000Z"} \ No newline at end of file diff --git a/.changepacks/changepack_log_MYPKUrMhUnJ3ZY5uU23ET.json b/.changepacks/changepack_log_MYPKUrMhUnJ3ZY5uU23ET.json index 3ac83c7a..63b952b0 100644 --- a/.changepacks/changepack_log_MYPKUrMhUnJ3ZY5uU23ET.json +++ b/.changepacks/changepack_log_MYPKUrMhUnJ3ZY5uU23ET.json @@ -1,12 +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" -} +{"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 From 86684fe4631030f4adba083e90523070d9aa0117 Mon Sep 17 00:00:00 2001 From: devfive Date: Mon, 14 Sep 2026 19:06:42 +0900 Subject: [PATCH 5/8] Keep an attached ampersand inside the open Roman section Rule 29 keeps a sign attached to Roman letters inside the open section. An ampersand followed by Hangul closed the section and reopened it for its own wrapper, writing a terminator and an indicator the reference does not. The section now closes at the Hangul instead. --- libs/braillify/AGENTS.md | 2 +- libs/braillify/src/english_logic.rs | 16 ++++++++++++++-- libs/braillify/src/rules/korean/rule_71.rs | 20 +++++++++++++++++++- 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/libs/braillify/AGENTS.md b/libs/braillify/AGENTS.md index 04d6e9c5..7a2bdcda 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,451/467,121 (97.50%).** +**Current status: 규정 fixture 5141/5141 (100%), 말뭉치 455,487/467,121 (97.51%).** `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 ac3a7874..ca13aeca 100644 --- a/libs/braillify/src/english_logic.rs +++ b/libs/braillify/src/english_logic.rs @@ -444,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. @@ -856,7 +866,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)] 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. From f06a0b46e88e5bb299ecb8220e8c7655f2170586 Mon Sep 17 00:00:00 2001 From: devfive Date: Mon, 14 Sep 2026 21:01:04 +0900 Subject: [PATCH 6/8] Open no Roman section for a slash that only digits precede Article 33's slash divides a unit from its base; it is part of article 74's digital notation only when Roman letters sit on its left. With digits alone (17.1/km, 16/32/64GB) the slash was read as an address separator and opened a Roman section, writing an indicator the reference does not. --- libs/braillify/AGENTS.md | 2 +- libs/braillify/src/english_logic.rs | 29 +++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/libs/braillify/AGENTS.md b/libs/braillify/AGENTS.md index 7a2bdcda..02dbc990 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,487/467,121 (97.51%).** +**Current status: 규정 fixture 5141/5141 (100%), 말뭉치 455,505/467,121 (97.51%).** `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 ca13aeca..30b4b242 100644 --- a/libs/braillify/src/english_logic.rs +++ b/libs/braillify/src/english_logic.rs @@ -549,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 @@ -1062,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] From 2ae5084c164158bde4619389b9c1ca41be6775d8 Mon Sep 17 00:00:00 2001 From: devfive Date: Wed, 16 Sep 2026 18:03:26 +0900 Subject: [PATCH 7/8] Drop a compound seam that leaves a one-letter component A component of a closed compound is never a single letter, so a seam after the first letter or before the last is table noise rather than a real boundary. Article 10.11.1 read the spurious seam in whole as a boundary that the wh groupsign bridged, and spelled the word out. --- libs/braillify/AGENTS.md | 2 +- .../src/rules/english_ueb/compound.rs | 29 ++++++++++++++++--- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/libs/braillify/AGENTS.md b/libs/braillify/AGENTS.md index 02dbc990..d567e803 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,505/467,121 (97.51%).** +**Current status: 규정 fixture 5141/5141 (100%), 말뭉치 455,517/467,121 (97.52%).** `KNOWN_FAILURES` 상수는 더 이상 존재하지 않는다. raw `encode()` 가 모든 testcase 에서 PDF 정답과 byte-동일 결과를 낸다. 새로 추가되는 testcase 도 같은 기준을 만족해야 한다. 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. From 9ab18fa30a12dd754e91b61cd290d4b2d9aaaa01 Mon Sep 17 00:00:00 2001 From: devfive Date: Wed, 16 Sep 2026 18:26:36 +0900 Subject: [PATCH 8/8] Contract an in that follows a hyphen in a Roman section --- .../changepack_log_Qv7bXe2LmTsdRk9pAoZuN.json | 1 + libs/braillify/AGENTS.md | 2 +- libs/braillify/src/rules/korean/rule_28.rs | 58 ++++++++++++++++++- 3 files changed, 57 insertions(+), 4 deletions(-) create mode 100644 .changepacks/changepack_log_Qv7bXe2LmTsdRk9pAoZuN.json 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/libs/braillify/AGENTS.md b/libs/braillify/AGENTS.md index d567e803..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,517/467,121 (97.52%).** +**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/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);