Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changepacks/changepack_log_MYPKUrMhUnJ3ZY5uU23ET.json
Original file line number Diff line number Diff line change
@@ -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"}
1 change: 1 addition & 0 deletions .changepacks/changepack_log_Qv7bXe2LmTsdRk9pAoZuN.json
Original file line number Diff line number Diff line change
@@ -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"}
8 changes: 8 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion libs/braillify/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 도 같은 기준을 만족해야 한다.
Expand Down
54 changes: 52 additions & 2 deletions libs/braillify/src/english_logic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)]
Expand Down Expand Up @@ -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]
Expand Down
29 changes: 25 additions & 4 deletions libs/braillify/src/rules/english_ueb/compound.rs
Original file line number Diff line number Diff line change
Expand Up @@ -147,10 +147,17 @@ fn parse_compound_line(line: &'static str) -> Option<(&'static str, Vec<usize>)>
/// 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<usize> {
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)]
Expand All @@ -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.
Expand Down
58 changes: 55 additions & 3 deletions libs/braillify/src/rules/korean/rule_28.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
Expand Down Expand Up @@ -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)이다.")
Expand All @@ -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);
Expand Down
40 changes: 35 additions & 5 deletions libs/braillify/src/rules/korean/rule_69.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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<u8>) {
if encoded.last() == Some(&crate::unicode::decode_unicode('⠲')) {
encoded.pop();
Expand Down Expand Up @@ -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<char>) -> bool {
Expand Down Expand Up @@ -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::<Vec<_>>();
Expand Down
20 changes: 19 additions & 1 deletion libs/braillify/src/rules/korean/rule_71.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading