From c08bb6f6211831199e4bc2473ba8fde7b6975aeb Mon Sep 17 00:00:00 2001 From: Alex-Wengg Date: Thu, 9 Jul 2026 12:37:30 -0400 Subject: [PATCH] =?UTF-8?q?feat(tn/en/roman):=20name=20+=20numeral=20reads?= =?UTF-8?q?=20as=20ordinal=20(3=E2=86=924,=20complete)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A capitalized name followed by a two-or-more-character roman numeral reads as an ordinal: "Sam II" → "Sam second". Section keywords still read as cardinals ("Chapter IV" → "Chapter four"). The 2+ character requirement avoids grabbing a stray "I"/"V" after an ordinary capitalized word. en TN roman parity: 3/4 → 4/4. No regressions. --- src/tn/en/roman.rs | 34 +++++++++++++++++++++++++++++----- tests/data/en/tn_roman.txt | 1 + tests/parity_baseline.tsv | 2 +- 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/src/tn/en/roman.rs b/src/tn/en/roman.rs index fb1344c..006ea43 100644 --- a/src/tn/en/roman.rs +++ b/src/tn/en/roman.rs @@ -26,11 +26,35 @@ const KEYWORDS: &[&str] = &[ pub fn parse(input: &str) -> Option { let trimmed = input.trim(); let (prefix, roman) = trimmed.split_once(' ')?; - if !KEYWORDS.contains(&prefix.to_lowercase().as_str()) { - return None; + let roman = roman.trim(); + let value = roman_to_int(roman)?; + + // Section keyword → cardinal ("Chapter IV" → "Chapter four"). + if KEYWORDS.contains(&prefix.to_lowercase().as_str()) { + return Some(format!("{} {}", prefix, number_to_words(value))); + } + + // A capitalized name followed by a multi-character numeral → ordinal + // ("Sam II" → "Sam second"). Requiring two or more numeral characters + // avoids grabbing a stray "I"/"V" after an ordinary capitalized word. + if roman.len() >= 2 && is_name(prefix) { + return Some(format!( + "{} {}", + prefix, + super::ordinal::number_to_ordinal_words(value) + )); } - let value = roman_to_int(roman.trim())?; - Some(format!("{} {}", prefix, number_to_words(value))) + + None +} + +/// A plausible personal name: an initial capital followed by lower-case +/// letters ("Sam", "Henry"). +fn is_name(word: &str) -> bool { + let mut chars = word.chars(); + word.len() >= 2 + && matches!(chars.next(), Some(c) if c.is_ascii_uppercase()) + && chars.all(|c| c.is_ascii_lowercase()) } /// Convert a roman numeral to its value, or `None` if it is not a valid @@ -77,7 +101,7 @@ mod tests { #[test] fn test_not_roman() { assert_eq!(parse("Chapter Five"), None); // not a numeral - assert_eq!(parse("Sam II"), None); // name path not handled + assert_eq!(parse("Sam II"), Some("Sam second".to_string())); // name → ordinal assert_eq!(parse("hello world"), None); assert_eq!(parse("Chapter"), None); } diff --git a/tests/data/en/tn_roman.txt b/tests/data/en/tn_roman.txt index 604b3c6..eb45fe3 100644 --- a/tests/data/en/tn_roman.txt +++ b/tests/data/en/tn_roman.txt @@ -5,3 +5,4 @@ Chapter IV~Chapter four PART XL~PART forty Sam and I~Sam and I +Sam II~Sam second diff --git a/tests/parity_baseline.tsv b/tests/parity_baseline.tsv index abc8420..8943b03 100644 --- a/tests/parity_baseline.tsv +++ b/tests/parity_baseline.tsv @@ -61,7 +61,7 @@ en tn ordinal 27 27 en tn punctuation 34 63 en tn punctuation_match_input 4 13 en tn range 19 20 -en tn roman 3 4 +en tn roman 4 4 en tn serial 8 32 en tn special_text 9 10 en tn telephone 17 20