@@ -1005,31 +1005,119 @@ pub fn normalize_sentence_with_options(input: &str, options: NormalizeOptions) -
10051005 normalize_sentence_inner ( input, max_span, options. concat_compound_numbers )
10061006}
10071007
1008- /// Sentence-mode dispatch loop. The `concat_compound` flag is forwarded to
1009- /// [`parse_span`] so each span sees the right tagger priorities.
1010- fn normalize_sentence_inner ( input : & str , max_span_tokens : usize , concat_compound : bool ) -> String {
1011- let trimmed = input. trim ( ) ;
1012- if trimmed. is_empty ( ) {
1013- return trimmed. to_string ( ) ;
1008+ /// Per-pretoken record: the token text plus the original separator that
1009+ /// preceded it (`" "` for whitespace, `""` if the token came from a
1010+ /// punctuation split or starts the input).
1011+ struct Pretoken {
1012+ text : String ,
1013+ sep : & ' static str ,
1014+ }
1015+
1016+ /// ASCII punctuation characters that are split off the leading or trailing
1017+ /// edge of a whitespace-separated word so taggers see clean number phrases
1018+ /// (issue #21). Apostrophes and hyphens are intentionally excluded so
1019+ /// contractions ("don't") and hyphenated words ("twenty-one") stay intact.
1020+ fn is_split_punct ( c : char ) -> bool {
1021+ matches ! (
1022+ c,
1023+ ',' | '.' | ';' | ':' | '!' | '?' | '(' | ')' | '[' | ']' | '{' | '}' | '"'
1024+ )
1025+ }
1026+
1027+ /// Split each whitespace-separated word into leading punctuation, core, and
1028+ /// trailing punctuation pretokens, preserving the original leading separator
1029+ /// on the first piece of each word so output reconstruction matches input
1030+ /// spacing.
1031+ fn pretokenize ( input : & str ) -> Vec < Pretoken > {
1032+ let mut out: Vec < Pretoken > = Vec :: new ( ) ;
1033+ let mut first_word = true ;
1034+
1035+ for word in input. split_whitespace ( ) {
1036+ let word_lead: & ' static str = if first_word { "" } else { " " } ;
1037+ first_word = false ;
1038+
1039+ // char_indices keeps multi-byte chars intact when slicing.
1040+ let chars: Vec < ( usize , char ) > = word. char_indices ( ) . collect ( ) ;
1041+ if chars. is_empty ( ) {
1042+ continue ;
1043+ }
1044+
1045+ let mut start = 0usize ;
1046+ while start < chars. len ( ) && is_split_punct ( chars[ start] . 1 ) {
1047+ start += 1 ;
1048+ }
1049+ let mut end = chars. len ( ) ;
1050+ while end > start && is_split_punct ( chars[ end - 1 ] . 1 ) {
1051+ end -= 1 ;
1052+ }
1053+
1054+ let mut next_sep = word_lead;
1055+
1056+ // Leading punctuation: each character becomes its own pretoken.
1057+ for & ( _, ch) in & chars[ ..start] {
1058+ out. push ( Pretoken {
1059+ text : ch. to_string ( ) ,
1060+ sep : next_sep,
1061+ } ) ;
1062+ next_sep = "" ;
1063+ }
1064+
1065+ // Core text (may be empty if the whole word was punctuation).
1066+ if start < end {
1067+ let core_start = chars[ start] . 0 ;
1068+ let core_end = if end < chars. len ( ) {
1069+ chars[ end] . 0
1070+ } else {
1071+ word. len ( )
1072+ } ;
1073+ out. push ( Pretoken {
1074+ text : word[ core_start..core_end] . to_string ( ) ,
1075+ sep : next_sep,
1076+ } ) ;
1077+ next_sep = "" ;
1078+ }
1079+
1080+ // Trailing punctuation.
1081+ for & ( _, ch) in & chars[ end..] {
1082+ out. push ( Pretoken {
1083+ text : ch. to_string ( ) ,
1084+ sep : next_sep,
1085+ } ) ;
1086+ next_sep = "" ;
1087+ }
10141088 }
10151089
1090+ out
1091+ }
1092+
1093+ /// Shared sliding-window match loop used by the three sentence-mode entry
1094+ /// points. `parser` returns `Some((replacement, score))` if the joined span
1095+ /// can be normalized.
1096+ fn sentence_loop < F > ( pretokens : & [ Pretoken ] , max_span_tokens : usize , parser : F ) -> String
1097+ where
1098+ F : Fn ( & str ) -> Option < ( String , u8 ) > ,
1099+ {
10161100 let max_span = if max_span_tokens == 0 {
10171101 1
10181102 } else {
10191103 max_span_tokens
10201104 } ;
1021- let tokens : Vec < & str > = trimmed . split_whitespace ( ) . collect ( ) ;
1022- let mut out: Vec < String > = Vec :: with_capacity ( tokens . len ( ) ) ;
1105+
1106+ let mut out = String :: new ( ) ;
10231107 let mut i = 0usize ;
10241108
1025- while i < tokens . len ( ) {
1026- let max_end = usize:: min ( tokens . len ( ) , i + max_span) ;
1109+ while i < pretokens . len ( ) {
1110+ let max_end = usize:: min ( pretokens . len ( ) , i + max_span) ;
10271111 let mut best: Option < ( usize , String , u8 ) > = None ;
10281112
10291113 // Longest-span-first search keeps replacements stable and non-overlapping.
10301114 for end in ( i + 1 ..=max_end) . rev ( ) {
1031- let span = tokens[ i..end] . join ( " " ) ;
1032- let Some ( ( candidate, score) ) = parse_span ( & span, concat_compound) else {
1115+ let span: String = pretokens[ i..end]
1116+ . iter ( )
1117+ . map ( |p| p. text . as_str ( ) )
1118+ . collect :: < Vec < _ > > ( )
1119+ . join ( " " ) ;
1120+ let Some ( ( candidate, score) ) = parser ( & span) else {
10331121 continue ;
10341122 } ;
10351123
@@ -1056,15 +1144,31 @@ fn normalize_sentence_inner(input: &str, max_span_tokens: usize, concat_compound
10561144 }
10571145
10581146 if let Some ( ( end, replacement, _) ) = best {
1059- out. push ( replacement) ;
1147+ out. push_str ( pretokens[ i] . sep ) ;
1148+ out. push_str ( & replacement) ;
10601149 i = end;
10611150 } else {
1062- out. push ( tokens[ i] . to_string ( ) ) ;
1151+ out. push_str ( pretokens[ i] . sep ) ;
1152+ out. push_str ( & pretokens[ i] . text ) ;
10631153 i += 1 ;
10641154 }
10651155 }
10661156
1067- out. join ( " " )
1157+ out
1158+ }
1159+
1160+ /// Sentence-mode dispatch loop. The `concat_compound` flag is forwarded to
1161+ /// [`parse_span`] so each span sees the right tagger priorities.
1162+ fn normalize_sentence_inner ( input : & str , max_span_tokens : usize , concat_compound : bool ) -> String {
1163+ let trimmed = input. trim ( ) ;
1164+ if trimmed. is_empty ( ) {
1165+ return trimmed. to_string ( ) ;
1166+ }
1167+
1168+ let pretokens = pretokenize ( trimmed) ;
1169+ sentence_loop ( & pretokens, max_span_tokens, |span| {
1170+ parse_span ( span, concat_compound)
1171+ } )
10681172}
10691173
10701174// ── Text Normalization (written → spoken) ─────────────────────────────
@@ -1210,56 +1314,10 @@ pub fn tn_normalize_sentence_with_max_span_lang(
12101314 return trimmed. to_string ( ) ;
12111315 }
12121316
1213- let max_span = if max_span_tokens == 0 {
1214- 1
1215- } else {
1216- max_span_tokens
1217- } ;
1218- let tokens: Vec < & str > = trimmed. split_whitespace ( ) . collect ( ) ;
1219- let mut out: Vec < String > = Vec :: with_capacity ( tokens. len ( ) ) ;
1220- let mut i = 0usize ;
1221-
1222- while i < tokens. len ( ) {
1223- let max_end = usize:: min ( tokens. len ( ) , i + max_span) ;
1224- let mut best: Option < ( usize , String , u8 ) > = None ;
1225-
1226- for end in ( i + 1 ..=max_end) . rev ( ) {
1227- let span = tokens[ i..end] . join ( " " ) ;
1228- let Some ( ( candidate, score) ) = tn_parse_span_lang ( & span, lang) else {
1229- continue ;
1230- } ;
1231-
1232- let candidate_trimmed = candidate. trim ( ) ;
1233- if candidate_trimmed. is_empty ( ) || candidate_trimmed == span {
1234- continue ;
1235- }
1236-
1237- let candidate_len = end - i;
1238- match & best {
1239- None => {
1240- best = Some ( ( end, candidate, score) ) ;
1241- }
1242- Some ( ( best_end, _, best_score) ) => {
1243- let best_len = * best_end - i;
1244- if candidate_len > best_len
1245- || ( candidate_len == best_len && score > * best_score)
1246- {
1247- best = Some ( ( end, candidate, score) ) ;
1248- }
1249- }
1250- }
1251- }
1252-
1253- if let Some ( ( end, replacement, _) ) = best {
1254- out. push ( replacement) ;
1255- i = end;
1256- } else {
1257- out. push ( tokens[ i] . to_string ( ) ) ;
1258- i += 1 ;
1259- }
1260- }
1261-
1262- out. join ( " " )
1317+ let pretokens = pretokenize ( trimmed) ;
1318+ sentence_loop ( & pretokens, max_span_tokens, |span| {
1319+ tn_parse_span_lang ( span, lang)
1320+ } )
12631321 }
12641322 }
12651323}
@@ -1271,56 +1329,8 @@ pub fn tn_normalize_sentence_with_max_span(input: &str, max_span_tokens: usize)
12711329 return trimmed. to_string ( ) ;
12721330 }
12731331
1274- let max_span = if max_span_tokens == 0 {
1275- 1
1276- } else {
1277- max_span_tokens
1278- } ;
1279- let tokens: Vec < & str > = trimmed. split_whitespace ( ) . collect ( ) ;
1280- let mut out: Vec < String > = Vec :: with_capacity ( tokens. len ( ) ) ;
1281- let mut i = 0usize ;
1282-
1283- while i < tokens. len ( ) {
1284- let max_end = usize:: min ( tokens. len ( ) , i + max_span) ;
1285- let mut best: Option < ( usize , String , u8 ) > = None ;
1286-
1287- for end in ( i + 1 ..=max_end) . rev ( ) {
1288- let span = tokens[ i..end] . join ( " " ) ;
1289- let Some ( ( candidate, score) ) = tn_parse_span ( & span) else {
1290- continue ;
1291- } ;
1292-
1293- let candidate_trimmed = candidate. trim ( ) ;
1294- if candidate_trimmed. is_empty ( ) || candidate_trimmed == span {
1295- continue ;
1296- }
1297-
1298- let candidate_len = end - i;
1299- match & best {
1300- None => {
1301- best = Some ( ( end, candidate, score) ) ;
1302- }
1303- Some ( ( best_end, _, best_score) ) => {
1304- let best_len = * best_end - i;
1305- if candidate_len > best_len
1306- || ( candidate_len == best_len && score > * best_score)
1307- {
1308- best = Some ( ( end, candidate, score) ) ;
1309- }
1310- }
1311- }
1312- }
1313-
1314- if let Some ( ( end, replacement, _) ) = best {
1315- out. push ( replacement) ;
1316- i = end;
1317- } else {
1318- out. push ( tokens[ i] . to_string ( ) ) ;
1319- i += 1 ;
1320- }
1321- }
1322-
1323- out. join ( " " )
1332+ let pretokens = pretokenize ( trimmed) ;
1333+ sentence_loop ( & pretokens, max_span_tokens, tn_parse_span)
13241334}
13251335
13261336#[ cfg( test) ]
0 commit comments