Skip to content

Commit bd70a7c

Browse files
authored
fix: split trailing punctuation in sentence mode (#21) (#25)
`normalize_sentence` and the TN sentence loops tokenized via `split_whitespace`, which left punctuation attached to the previous word (e.g. `"eight,"`). The cardinal/aviation taggers then never saw a clean number phrase, producing wrong output like `"United 780 eight,"` for `"United seven eighty eight, please ..."`. Add a `pretokenize` helper that peels off leading/trailing ASCII punctuation (`, . ; : ! ? ( ) [ ] { } "`) into separate pretokens while remembering each token's original leading separator (`" "` or `""`), and factor the three identical sliding-window loops (`normalize_sentence_inner`, `tn_normalize_sentence_with_max_span`, `tn_normalize_sentence_with_max_span_lang`) into a single shared `sentence_loop`. Output is reconstructed using the preserved separators so spacing matches the input exactly: `"eight,"` becomes `["eight", ","]` internally and re-emerges as `"788,"`, while `"eight , please"` (explicit space) still emits `"788 , please"`. Apostrophes and hyphens are intentionally excluded from the split set so contractions ("don't") and hyphenated words ("twenty-one") stay intact.
1 parent d3f314b commit bd70a7c

2 files changed

Lines changed: 190 additions & 115 deletions

File tree

src/lib.rs

Lines changed: 125 additions & 115 deletions
Original file line numberDiff line numberDiff line change
@@ -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)]

tests/en_tests.rs

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1125,3 +1125,68 @@ fn test_issue_23_compound_concat() {
11251125
"2017"
11261126
);
11271127
}
1128+
1129+
/// Issue #21: trailing punctuation glued to the last word of a number phrase
1130+
/// (e.g. `"eight,"`) used to block the cardinal tagger because
1131+
/// `split_whitespace` left punctuation attached. The pretokenizer splits
1132+
/// leading/trailing ASCII punctuation off each whitespace token while
1133+
/// preserving the original spacing on output.
1134+
#[test]
1135+
fn test_issue_21_trailing_comma() {
1136+
let opts = concat_opts();
1137+
assert_eq!(
1138+
normalize_sentence_with_options(
1139+
"United seven eighty eight, please come up on frequency one three five point six two five, thanks.",
1140+
opts,
1141+
),
1142+
"United 788, please come up on frequency 135.625, thanks."
1143+
);
1144+
}
1145+
1146+
#[test]
1147+
fn test_issue_21_trailing_period() {
1148+
let opts = concat_opts();
1149+
assert_eq!(
1150+
normalize_sentence_with_options(
1151+
"United seven eighty eight. please come up on frequency one three five point six two five. thanks.",
1152+
opts,
1153+
),
1154+
"United 788. please come up on frequency 135.625. thanks."
1155+
);
1156+
}
1157+
1158+
/// The pre-existing space-before-punctuation case must continue to work,
1159+
/// preserving the explicit space in output.
1160+
#[test]
1161+
fn test_issue_21_space_before_punct_preserved() {
1162+
let opts = concat_opts();
1163+
assert_eq!(
1164+
normalize_sentence_with_options(
1165+
"United seven eighty eight , please come up on frequency one three five point six two five , thanks .",
1166+
opts,
1167+
),
1168+
"United 788 , please come up on frequency 135.625 , thanks ."
1169+
);
1170+
}
1171+
1172+
/// Punctuation other than `,` and `.` is also split, including paired
1173+
/// brackets and quotes, while contractions and hyphenated words remain
1174+
/// intact.
1175+
#[test]
1176+
fn test_issue_21_other_punctuation() {
1177+
// Default options (no concat) still benefits from the fix.
1178+
assert_eq!(
1179+
normalize_sentence("I have twenty one apples."),
1180+
"I have 21 apples."
1181+
);
1182+
assert_eq!(
1183+
normalize_sentence("I have (twenty one) apples!"),
1184+
"I have (21) apples!"
1185+
);
1186+
assert_eq!(normalize_sentence("\"twenty one\" apples"), "\"21\" apples");
1187+
// Apostrophe inside contraction is NOT split.
1188+
assert_eq!(
1189+
normalize_sentence("don't eat twenty one apples"),
1190+
"don't eat 21 apples"
1191+
);
1192+
}

0 commit comments

Comments
 (0)