use crate::char_struct::CharType;
use crate::rules::RuleMeta;
use crate::rules::context::{EncodingMode, RuleContext};
use crate::rules::traits::{BrailleRule, Phase, RuleResult};
pub static META: RuleMeta = RuleMeta {
section: "71",
subsection: None,
name: "information_symbols",
standard_ref: "2024 Korean Braille Standard, Ch.6 Art.71",
description: "Keyboard, copyright, and information symbols",
};
const MAPPINGS: &[(char, &str)] = &[
('@', "⠈⠁"),
('^', "⠈⠢"),
('#', "⠸⠹"),
('|', "⠸⠳"),
('│', "⠸⠳"),
('\\', "⠸⠡"),
('&', "⠈⠯"),
('§', "⠘⠎"),
('¶', "⠘⠏"),
('©', "⠘⠉"),
('®', "⠘⠗"),
('™', "⠘⠞"),
];
fn encode_unicode_cells(unicode: &str) -> Vec<u8> {
unicode
.chars()
.map(crate::unicode::decode_unicode)
.collect()
}
fn should_wrap_information_symbol(ctx: &RuleContext) -> bool {
if ctx.word_len() > 1 {
return true;
}
let is_letter_like =
|ch: &char| ch.is_ascii_alphanumeric() || crate::utils::is_korean_char(*ch);
let prev_ends_korean = ctx
.prev_word
.chars()
.rev()
.find(is_letter_like)
.is_some_and(crate::utils::is_korean_char);
let next_starts_korean = ctx
.remaining_words
.first()
.and_then(|word| word.chars().find(is_letter_like))
.is_some_and(crate::utils::is_korean_char);
prev_ends_korean || next_starts_korean
}
fn follows_roman_word_in_open_section(ctx: &RuleContext) -> bool {
matches!(ctx.current_char(), '®' | '™' | '&')
&& ctx.state.is_english
&& ctx.prev_char().is_some_and(|ch| ch.is_ascii_alphanumeric())
}
fn is_attached_roman_ampersand(ctx: &RuleContext) -> bool {
crate::english_logic::is_attached_ascii_roman_ampersand(ctx.word_chars, ctx.index)
}
fn begins_attached_roman_segment(ctx: &RuleContext) -> bool {
crate::english_logic::is_ampersand_before_attached_ascii_roman_segment(
ctx.word_chars,
ctx.index,
)
}
pub fn is_rule_71_symbol(c: char) -> bool {
MAPPINGS.iter().any(|(candidate, _)| *candidate == c)
}
pub struct Rule71;
impl BrailleRule for Rule71 {
fn meta(&self) -> &'static RuleMeta {
&META
}
fn phase(&self) -> Phase {
Phase::CoreEncoding
}
fn priority(&self) -> u16 {
175
}
fn matches(&self, ctx: &RuleContext) -> bool {
ctx.state.current_mode() != EncodingMode::Math
&& matches!(ctx.char_type, CharType::Symbol(c) if is_rule_71_symbol(*c))
}
fn apply(&self, ctx: &mut RuleContext) -> Result<RuleResult, String> {
if ctx.current_char() == '§' {
if should_wrap_information_symbol(ctx) {
let mut encoded = vec![crate::unicode::decode_unicode('⠴')];
encoded.extend(encode_unicode_cells("⠘⠎"));
if !ctx.next_char().is_some_and(|ch| ch.is_ascii_digit()) {
encoded.push(crate::unicode::decode_unicode('⠲'));
}
ctx.emit_slice(&encoded);
return Ok(RuleResult::Consumed);
}
let encoded = encode_unicode_cells("⠘⠎");
ctx.emit_slice(&encoded);
return Ok(RuleResult::Consumed);
}
let Some((_, unicode)) = MAPPINGS
.iter()
.find(|(candidate, _)| *candidate == ctx.current_char())
else {
return Ok(RuleResult::Skip);
};
let mut encoded = Vec::new();
if should_wrap_information_symbol(ctx)
&& ctx.current_char() == '&'
&& begins_attached_roman_segment(ctx)
{
if !ctx.state.is_english {
if ctx.state.english_dominant_no_indicator {
crate::rules::roman_mode::mark_section_open(ctx.state);
} else {
crate::rules::roman_mode::enter_english(ctx.state, ctx.result);
}
}
encoded = encode_unicode_cells(unicode);
} else if should_wrap_information_symbol(ctx)
&& matches!(ctx.current_char(), '&' | '¶' | '©' | '®' | '™')
&& !is_attached_roman_ampersand(ctx)
&& !follows_roman_word_in_open_section(ctx)
{
encoded.push(crate::unicode::decode_unicode('⠴'));
encoded.extend(encode_unicode_cells(unicode));
if !ctx.next_char().is_some_and(|ch| ch.is_ascii_digit()) {
encoded.push(crate::unicode::decode_unicode('⠲'));
}
} else {
encoded = encode_unicode_cells(unicode);
}
if ctx.current_char() == '│' && ctx.prev_char().is_some() {
ctx.emit(0);
}
ctx.emit_slice(&encoded);
if ctx.current_char() == '│' && ctx.next_char().is_some() {
ctx.emit(0);
}
Ok(RuleResult::Consumed)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn apply_exercise() {
let mut owned = crate::test_helpers::CtxOwned::for_text("A", false);
let mut ctx = owned.ctx_at(0);
let _ = Rule71.apply(&mut ctx);
}
#[test]
fn matches_does_not_panic() {
let mut owned = crate::test_helpers::CtxOwned::for_text("A", false);
let ctx = owned.ctx_at(0);
let _ = Rule71.matches(&ctx);
}
#[rstest::rstest]
#[case::official_digit_continuation("헌법§1①", "⠴⠘⠎")]
#[case::word_end("헌법§", "⠴⠘⠎⠲")]
#[case::non_digit_continuation("헌법§A", "⠴⠘⠎⠲")]
fn section_sign_wrapper_terminator_boundary(#[case] input: &str, #[case] expected: &str) {
let section_index = input.chars().position(|ch| ch == '§').unwrap();
let mut owned = crate::test_helpers::CtxOwned::for_text(input, false);
let mut ctx = owned.ctx_at(section_index);
let outcome = Rule71.apply(&mut ctx).unwrap();
assert!(matches!(outcome, RuleResult::Consumed));
assert_eq!(ctx.result.as_slice(), encode_unicode_cells(expected));
}
#[rstest::rstest]
#[case::official_at_and_t("AT&T")]
#[case::official_b_and_b("B&B")]
fn attached_roman_ampersand_emits_bare_rule_71_cells(#[case] input: &str) {
let mut owned = crate::test_helpers::CtxOwned::for_text(input, false);
let ampersand_index = input.chars().position(|ch| ch == '&').unwrap();
let mut ctx = owned.ctx_at(ampersand_index);
let outcome = Rule71.apply(&mut ctx).unwrap();
assert!(matches!(outcome, RuleResult::Consumed));
assert_eq!(ctx.result.as_slice(), encode_unicode_cells("⠈⠯"));
}
#[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:?}"
);
}
#[test]
fn one_sided_official_ampersand_opens_and_keeps_roman_section() {
let mut owned = crate::test_helpers::CtxOwned::for_text("&c", true);
let mut ctx = owned.ctx_at(0);
let outcome = Rule71.apply(&mut ctx).unwrap();
assert!(matches!(outcome, RuleResult::Consumed));
assert_eq!(ctx.result.as_slice(), encode_unicode_cells("⠴⠈⠯"));
assert!(ctx.state.is_english);
}
#[test]
fn attached_ampersand_resumes_indicator_free_english_dominant_context() {
let mut owned = crate::test_helpers::CtxOwned::for_text("&c", true);
owned.state.english_dominant_no_indicator = true;
let mut ctx = owned.ctx_at(0);
let outcome = Rule71.apply(&mut ctx).unwrap();
assert!(matches!(outcome, RuleResult::Consumed));
assert_eq!(ctx.result.as_slice(), encode_unicode_cells("⠈⠯"));
assert!(ctx.state.is_english);
assert!(!ctx.state.needs_english_continuation);
assert!(!ctx.state.roman_number_chain);
}
#[rstest::rstest]
#[case::official_at_and_t("AT&T", "⠠⠠⠁⠞⠈⠯⠠⠞")]
#[case::official_b_and_b("B&B", "⠠⠃⠈⠯⠠⠃")]
fn full_encoder_preserves_official_ueb_ampersand_examples(
#[case] input: &str,
#[case] expected: &str,
) {
assert_eq!(crate::encode_to_unicode(input).unwrap(), expected);
}
#[test]
fn full_encoder_keeps_ampersand_roman_number_chain() {
assert_eq!(
crate::encode_to_unicode("가 AT&T3 나").unwrap(),
"⠫⠀⠴⠠⠠⠁⠞⠈⠯⠠⠞⠼⠉⠀⠉"
);
}
#[rstest::rstest]
#[case::official_at_and_t("가 AT&T 나", "⠫⠀⠴⠠⠠⠁⠞⠈⠯⠠⠞⠲⠀⠉")]
#[case::official_b_and_b("가 B&B 나", "⠫⠀⠴⠠⠃⠈⠯⠠⠃⠲⠀⠉")]
fn korean_wrapper_preserves_ampersand_capitalization_extent(
#[case] input: &str,
#[case] expected: &str,
) {
assert_eq!(crate::encode_to_unicode(input).as_deref(), Ok(expected));
}
#[rstest::rstest]
#[case::standalone("│", "|")]
#[case::spaced("저자 │ 홍길동", "저자 | 홍길동")]
#[case::attached("제작│감독", "제작 | 감독")]
fn box_drawing_vertical_line_matches_rule_71_print_form(
#[case] presentation: &str,
#[case] standard_print: &str,
) {
assert_eq!(
crate::encode_to_unicode(presentation),
crate::encode_to_unicode(standard_print)
);
}
#[test]
fn full_encoder_preserves_official_korean_spaced_ampersand_example() {
assert_eq!(
crate::encode_to_unicode("종이접기 & 클레이아트").unwrap(),
"⠨⠿⠕⠨⠎⠃⠈⠕⠀⠴⠈⠯⠲⠀⠋⠮⠐⠝⠕⠣⠓⠪",
);
}
}