use crate::rules::context::EncoderState;
use crate::rules::english_shortform::{
permits_grade1_boundary_after_run, requires_grade1_indicator,
};
use crate::rules::english_ueb::rule_10_9::requires_grade1_before_spelled_letters;
use crate::rules::english_ueb::rule_10_12::{
RomanRunPosition, is_letter_initialism_in_korean_text,
};
use crate::rules::token::{ModeEvent, Token, WordToken};
use crate::rules::token_rule::{TokenAction, TokenPhase, TokenRule};
pub struct UppercasePassageRule;
fn capitals_run_needs_grade1(
state: &EncoderState,
tokens: &[Token<'_>],
index: usize,
word: &WordToken<'_>,
run_start: usize,
run_end: usize,
) -> bool {
if !permits_grade1_boundary_after_run(&word.chars[run_end..]) {
return false;
}
let uppercase_run = word.chars[run_start..run_end].iter().collect::<String>();
let position = RomanRunPosition {
word_chars: &word.chars,
run_start,
run_end,
prev_word: prev_word(tokens, index).map_or("", |prev| prev.text.as_ref()),
next_word: next_two_words(tokens, index)
.0
.map(|next| next.text.as_ref()),
};
if is_letter_initialism_in_korean_text(state, &position) {
requires_grade1_before_spelled_letters(&uppercase_run)
} else {
requires_grade1_indicator(&uppercase_run)
}
}
fn prev_word<'a>(tokens: &'a [Token<'a>], index: usize) -> Option<&'a WordToken<'a>> {
tokens[..index].iter().rev().find_map(|t| {
if let Token::Word(w) = t {
Some(w)
} else {
None
}
})
}
fn next_two_words<'a>(
tokens: &'a [Token<'a>],
index: usize,
) -> (Option<&'a WordToken<'a>>, Option<&'a WordToken<'a>>) {
let mut iter = tokens.iter().skip(index + 1).filter_map(|t| {
if let Token::Word(w) = t {
Some(w)
} else {
None
}
});
let first = iter.next();
let second = iter.next();
(first, second)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct CapitalizedGroup {
start: usize,
end: usize,
}
fn is_opening_passage_punctuation(ch: char) -> bool {
matches!(
ch,
'\'' | '"' | '\u{2018}' | '\u{201c}' | '(' | '[' | '{' | '〈' | '《' | '「' | '『'
)
}
fn is_closing_passage_quote(ch: char) -> bool {
matches!(
ch,
'\'' | '"' | '\u{2019}' | '\u{201d}' | '〉' | '》' | '」' | '』'
)
}
fn capitalized_group(word: &WordToken<'_>) -> Option<CapitalizedGroup> {
if word.chars.iter().any(char::is_ascii_lowercase) {
return None;
}
let start = word.chars.iter().position(char::is_ascii_uppercase)?;
let prefix = &word.chars[..start];
let korean_headword_then_bracket = prefix
.last()
.is_some_and(|ch| matches!(ch, '(' | '[' | '{'))
&& prefix[..prefix.len() - 1]
.iter()
.all(|ch| crate::utils::is_korean_char(*ch) || is_opening_passage_punctuation(*ch));
if !prefix.iter().copied().all(is_opening_passage_punctuation) && !korean_headword_then_bracket
{
return None;
}
let last_capital = word.chars.iter().rposition(char::is_ascii_uppercase)?;
if word.chars[start..=last_capital]
.iter()
.any(|ch| crate::utils::is_korean_char(*ch))
{
return None;
}
let mut end = word.chars.len();
for index in last_capital + 1..word.chars.len() {
let ch = word.chars[index];
let opens_attached_korean_gloss = matches!(ch, '(' | '[' | '{')
&& word.chars[index + 1..]
.iter()
.any(|next| crate::utils::is_korean_char(*next));
if crate::utils::is_korean_char(ch)
|| is_closing_passage_quote(ch)
|| matches!(ch, ')' | ']' | '}')
|| opens_attached_korean_gloss
{
end = index;
break;
}
}
Some(CapitalizedGroup { start, end })
}
fn owned_word<'a>(chars: &[char]) -> Token<'a> {
let text = chars.iter().collect::<String>();
Token::Word(WordToken {
text: std::borrow::Cow::Owned(text),
chars: chars.to_vec(),
meta: crate::rules::token::WordMeta::from_chars(chars),
})
}
fn is_separated_rule_69_unit(tokens: &[Token<'_>], index: usize, word: &WordToken<'_>) -> bool {
let Some(previous) = prev_word(tokens, index) else {
return false;
};
let previous_is_number = previous.chars.iter().any(char::is_ascii_digit)
&& previous
.chars
.iter()
.all(|ch| ch.is_ascii_digit() || matches!(ch, ',' | '.'));
previous_is_number
&& crate::rules::korean::rule_69::complete_ascii_unit_len(&word.chars, 0).is_some()
}
fn is_single_capital_comma_item(word: &WordToken<'_>) -> bool {
word.chars.first().is_some_and(char::is_ascii_uppercase)
&& word.chars.get(1) == Some(&',')
&& word
.chars
.iter()
.filter(|ch| ch.is_ascii_uppercase())
.count()
== 1
}
fn is_korean_math_letter_list_start(
tokens: &[Token<'_>],
index: usize,
word: &WordToken<'_>,
upcoming_first: Option<&WordToken<'_>>,
upcoming_second: Option<&WordToken<'_>>,
) -> bool {
let previous_is_korean =
prev_word(tokens, index).is_some_and(|previous| previous.meta.has_korean);
let Some(first) = upcoming_first else {
return false;
};
let Some(second) = upcoming_second else {
return false;
};
let second_group = capitalized_group(second);
let second_has_one_capital = second
.chars
.iter()
.filter(|ch| ch.is_ascii_uppercase())
.count()
== 1;
let second_has_attached_korean = second_group.is_some_and(|group| {
second.chars[group.end..]
.iter()
.any(|ch| crate::utils::is_korean_char(*ch))
});
previous_is_korean
&& is_single_capital_comma_item(word)
&& is_single_capital_comma_item(first)
&& second_has_one_capital
&& second_has_attached_korean
}
impl TokenRule for UppercasePassageRule {
fn phase(&self) -> TokenPhase {
TokenPhase::UppercasePassage
}
fn priority(&self) -> u16 {
100
}
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 mut prefix = Vec::new();
let mut suffix = Vec::new();
let (upcoming_first, upcoming_second) = next_two_words(tokens, index);
let word_len = word.chars.len();
let ascii_starts_at_beginning = word.meta.starts_with_ascii;
let capitalized = capitalized_group(word);
let previous_keeps_section_open = prev_word(tokens, index).is_some_and(|previous| {
previous
.chars
.iter()
.rev()
.find(|ch| ch.is_alphanumeric())
.is_some_and(char::is_ascii_alphanumeric)
&& matches!(tokens.get(index - 1), Some(Token::Space(_)))
});
let needs_inline_entry = state.english_indicator
&& !state.is_english
&& !previous_keeps_section_open
&& word.meta.has_ascii_alphabetic
&& capitalized.is_some();
let upcoming_first_group = upcoming_first.and_then(capitalized_group);
let upcoming_second_group = upcoming_second.and_then(capitalized_group);
let is_korean_math_letter_list =
is_korean_math_letter_list_start(tokens, index, word, upcoming_first, upcoming_second);
let can_start_passage = capitalized.is_some_and(|group| group.end == word_len)
&& upcoming_first
.zip(upcoming_first_group)
.is_some_and(|(next, group)| group.start == 0 && group.end == next.chars.len())
&& upcoming_second_group.is_some_and(|group| group.start == 0)
&& !is_korean_math_letter_list
&& !is_separated_rule_69_unit(tokens, index, word);
if can_start_passage && !state.triple_big_english {
let group = capitalized.expect("passage start has a capitalized group");
let mut replacement = Vec::new();
if group.start > 0 {
replacement.push(owned_word(&word.chars[..group.start]));
}
if needs_inline_entry {
let entry = if state.needs_english_continuation {
ModeEvent::EnterEnglishContinue
} else {
ModeEvent::EnterEnglish
};
replacement.push(Token::Mode(entry));
crate::rules::roman_mode::set_section_open_keeping_number_chain(state, true);
}
let uppercase_run_len = word
.chars
.iter()
.skip(group.start)
.take_while(|ch| ch.is_ascii_uppercase())
.count();
let uppercase_run_end = group.start + uppercase_run_len;
if capitals_run_needs_grade1(state, tokens, index, word, group.start, uppercase_run_end)
{
replacement.push(Token::Mode(ModeEvent::Grade1Indicator));
}
replacement.push(Token::Mode(ModeEvent::CapsPassageStart));
replacement.push(owned_word(&word.chars[group.start..]));
state.triple_big_english = true;
state.has_processed_word = true;
return Ok(TokenAction::ReplaceMany(replacement));
}
if word.meta.is_all_uppercase
&& !state.triple_big_english
&& ascii_starts_at_beginning
&& !is_separated_rule_69_unit(tokens, index, word)
{
if needs_inline_entry {
let entry = if state.needs_english_continuation {
ModeEvent::EnterEnglishContinue
} else {
ModeEvent::EnterEnglish
};
prefix.push(Token::Mode(entry));
crate::rules::roman_mode::set_section_open_keeping_number_chain(state, true);
}
let uppercase_run_len = word
.chars
.iter()
.take_while(|ch| ch.is_ascii_uppercase())
.count();
let needs_grade1 =
capitals_run_needs_grade1(state, tokens, index, word, 0, uppercase_run_len);
if word_len >= 2 {
if needs_grade1 {
prefix.push(Token::Mode(ModeEvent::Grade1Indicator));
}
prefix.push(Token::Mode(ModeEvent::CapsWord));
}
}
let next_continues_passage = upcoming_first_group.is_some_and(|group| group.start == 0);
if state.triple_big_english && !next_continues_passage {
state.triple_big_english = false;
let next_starts_roman = upcoming_first.is_some_and(|next| {
next.chars
.iter()
.find(|ch| ch.is_alphanumeric())
.is_some_and(char::is_ascii_alphanumeric)
});
let korean_punctuation_tail = word
.chars
.iter()
.rev()
.take_while(|ch| matches!(ch, ',' | '.' | ';' | ':'))
.count();
let group = capitalized.map(|group| {
if group.end == word_len && korean_punctuation_tail > 0 && !next_starts_roman {
CapitalizedGroup {
start: group.start,
end: word_len - korean_punctuation_tail,
}
} else {
group
}
});
if let Some(group) = group
&& group.start == 0
&& group.end < word_len
{
let replacement = vec![
owned_word(&word.chars[..group.end]),
Token::Mode(ModeEvent::CapsPassageEnd),
owned_word(&word.chars[group.end..]),
];
state.has_processed_word = true;
return Ok(TokenAction::ReplaceMany(replacement));
}
suffix.push(Token::Mode(ModeEvent::CapsPassageEnd));
}
if !state.has_processed_word {
state.has_processed_word = true;
}
if prefix.is_empty() && suffix.is_empty() {
return Ok(TokenAction::Noop);
}
let mut replacement = Vec::with_capacity(prefix.len() + 1 + suffix.len());
replacement.extend(prefix);
replacement.push(Token::Word(word.clone()));
replacement.extend(suffix);
Ok(TokenAction::ReplaceMany(replacement))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::rules::context::EncoderState;
use crate::rules::token::{SpaceKind, WordMeta};
use std::borrow::Cow;
fn word(text: &str) -> Token<'static> {
let chars: Vec<char> = text.chars().collect();
Token::Word(WordToken {
text: Cow::Owned(text.to_string()),
chars: chars.clone(),
meta: WordMeta::from_chars(&chars),
})
}
fn spaced_words(words: &[&str]) -> Vec<Token<'static>> {
let mut tokens = Vec::with_capacity(words.len().saturating_mul(2).saturating_sub(1));
for (index, value) in words.iter().enumerate() {
if index > 0 {
tokens.push(Token::Space(SpaceKind::Regular));
}
tokens.push(word(value));
}
tokens
}
fn replacement_words<'a>(tokens: &'a [Token<'_>]) -> Vec<&'a str> {
tokens
.iter()
.filter_map(|token| match token {
Token::Word(word) => Some(word.text.as_ref()),
_ => None,
})
.collect()
}
#[test]
fn uppercase_passage_enter_english_continue_direct() {
let r = UppercasePassageRule;
let mut state = EncoderState::new(false);
state.english_indicator = true;
state.is_english = false; state.needs_english_continuation = true; let tokens = vec![
word("ABC"),
Token::Space(SpaceKind::Regular),
word("DEF"),
Token::Space(SpaceKind::Regular),
word("GHI"),
];
let action = r.apply(&tokens, 0, &mut state).unwrap();
let found = matches!(action, TokenAction::ReplaceMany(ref ts)
if ts.iter().any(|t| matches!(t, Token::Mode(ModeEvent::EnterEnglishContinue))));
assert!(found, "expected EnterEnglishContinue Mode token");
}
#[test]
fn uppercase_passage_enter_english_direct() {
let r = UppercasePassageRule;
let mut state = EncoderState::new(false);
state.english_indicator = true;
state.is_english = false;
state.needs_english_continuation = false;
let tokens = vec![
word("ABC"),
Token::Space(SpaceKind::Regular),
word("DEF"),
Token::Space(SpaceKind::Regular),
word("GHI"),
];
let action = r.apply(&tokens, 0, &mut state).unwrap();
let found = matches!(action, TokenAction::ReplaceMany(ref ts)
if ts.iter().any(|t| matches!(t, Token::Mode(ModeEvent::EnterEnglish))));
assert!(found, "expected EnterEnglish Mode token");
}
#[rstest::rstest]
#[case::bare_cd("CD", true)]
#[case::llc_before_closing_group("LLC)", true)]
#[case::llc_before_korean_code_span("LLC회사", true)]
#[case::cd_before_digit("CD47", false)]
#[case::cd_before_slash("CD/ATM", false)]
#[case::neither_s_before_plus("NEIS+", false)]
#[case::little_m_before_opening_group("LLM(SLM)", true)]
fn uppercase_passage_grade1_respects_letters_sequence_boundary(
#[case] input: &str,
#[case] expected: bool,
) {
let r = UppercasePassageRule;
let mut state = EncoderState::new(false);
state.english_indicator = true;
state.is_english = false;
let tokens = vec![word(input)];
let action = r.apply(&tokens, 0, &mut state).unwrap();
let found = matches!(action, TokenAction::ReplaceMany(ref ts)
if ts.iter().any(|t| matches!(t, Token::Mode(ModeEvent::Grade1Indicator))));
assert_eq!(found, expected);
}
#[test]
fn capitalized_group_rejects_korean_inside_the_capital_extent() {
let Token::Word(word) = word("A한B") else {
unreachable!("helper always builds a word")
};
assert_eq!(capitalized_group(&word), None);
}
#[test]
fn shortform_collision_before_capitals_passage_gets_grade1() {
let rule = UppercasePassageRule;
let tokens = spaced_words(&["CD", "EF", "GH"]);
let mut state = EncoderState::new(false);
state.english_indicator = true;
let TokenAction::ReplaceMany(replacement) =
rule.apply(&tokens, 0, &mut state).expect("passage starts")
else {
panic!("expected a passage-start replacement")
};
assert!(replacement.windows(2).any(|window| matches!(
window,
[
Token::Mode(ModeEvent::Grade1Indicator),
Token::Mode(ModeEvent::CapsPassageStart)
]
)));
}
#[rstest::rstest]
#[case::pdf_gigabyte("GB")]
#[case::petabyte("PB")]
#[case::terabyte("TB")]
fn separated_uppercase_units_do_not_preemit_ueb_modes(#[case] unit: &str) {
let r = UppercasePassageRule;
let mut state = EncoderState::new(false);
state.english_indicator = true;
let tokens = vec![word("5"), Token::Space(SpaceKind::Regular), word(unit)];
assert!(matches!(
r.apply(&tokens, 2, &mut state).unwrap(),
TokenAction::Noop
));
}
#[rstest::rstest]
#[case::caution_with_quote(
&["‘CAUTION:", "WET", "PAINT!’이다."],
&["‘", "CAUTION:"],
&["PAINT!", "’이다."]
)]
#[case::bbc_news_with_quote(
&["“THE", "BBC", "AFRICA", "NEWS”이다."],
&["“", "THE"],
&["NEWS", "”이다."]
)]
#[case::self_made_man_with_gloss(
&["A", "SELF-MADE", "MAN(남자)이다."],
&["A"],
&["MAN", "(남자)이다."]
)]
fn capitalized_passage_respects_attached_mixed_script_boundaries(
#[case] words: &[&str],
#[case] expected_start_words: &[&str],
#[case] expected_end_words: &[&str],
) {
let rule = UppercasePassageRule;
let tokens = spaced_words(words);
let mut state = EncoderState::new(false);
state.english_indicator = true;
let TokenAction::ReplaceMany(start) = rule
.apply(&tokens, 0, &mut state)
.expect("official capitalised passage must start")
else {
panic!("expected a passage-start replacement");
};
assert_eq!(replacement_words(&start), expected_start_words);
assert_eq!(
start
.iter()
.filter(|token| matches!(token, Token::Mode(ModeEvent::CapsPassageStart)))
.count(),
1
);
assert!(
!start
.iter()
.any(|token| matches!(token, Token::Mode(ModeEvent::CapsWord)))
);
assert!(state.triple_big_english);
let last_index = tokens.len() - 1;
let TokenAction::ReplaceMany(end) = rule
.apply(&tokens, last_index, &mut state)
.expect("official capitalised passage must terminate")
else {
panic!("expected a passage-end replacement");
};
assert_eq!(replacement_words(&end), expected_end_words);
assert!(matches!(
end.get(1),
Some(Token::Mode(ModeEvent::CapsPassageEnd))
));
assert!(!state.triple_big_english);
}
#[test]
fn korean_math_letter_list_does_not_start_capitals_passage() {
let ir = crate::rules::token::DocumentIR::parse("세 점 A, B, C가 있다.", true);
let index = ir
.tokens
.iter()
.position(|token| matches!(token, Token::Word(word) if word.text == "A,"))
.expect("official example must contain its first Roman variable");
let mut state = EncoderState::new(true);
assert!(matches!(
UppercasePassageRule
.apply(&ir.tokens, index, &mut state)
.expect("letter list classification must succeed"),
TokenAction::Noop
));
assert!(!state.triple_big_english);
}
}