1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub struct Quotes {
9 pub open_double: char,
10 pub close_double: char,
11 pub open_single: char,
12 pub close_single: char,
13}
14
15impl Quotes {
16 pub const EN: Self = Self {
18 open_double: '\u{201C}',
19 close_double: '\u{201D}',
20 open_single: '\u{2018}',
21 close_single: '\u{2019}',
22 };
23 pub const DE: Self = Self {
25 open_double: '\u{201E}',
26 close_double: '\u{201C}',
27 open_single: '\u{201A}',
28 close_single: '\u{2018}',
29 };
30 pub const DE_GUILLEMETS: Self = Self {
32 open_double: '\u{00BB}',
33 close_double: '\u{00AB}',
34 open_single: '\u{203A}',
35 close_single: '\u{2039}',
36 };
37
38 pub fn for_lang(primary: &str) -> Self {
39 match primary {
40 "de" => Self::DE,
41 _ => Self::EN,
42 }
43 }
44}
45
46const APOSTROPHE: char = '\u{2019}';
47const NBSP: char = '\u{00A0}';
48const NNBSP: char = '\u{202F}';
50const EN_DASH: char = '\u{2013}';
51const EM_DASH: char = '\u{2014}';
52const ELLIPSIS: char = '\u{2026}';
53const SOFT_HYPHEN: char = '\u{00AD}';
56
57#[derive(Debug, Clone, Copy)]
59pub struct SmartenOptions {
60 pub quotes: Quotes,
61 pub german_dashes: bool,
64}
65
66impl SmartenOptions {
67 pub fn for_lang(primary: &str) -> Self {
68 Self {
69 quotes: Quotes::for_lang(primary),
70 german_dashes: primary == "de",
71 }
72 }
73}
74
75#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
80pub struct Carry {
81 pub prev: Option<char>,
84 pub single_open: bool,
88 pub double_open: bool,
91}
92
93pub fn smarten(s: &str, carry: &mut Carry, opts: SmartenOptions) -> String {
98 let chars: Vec<char> = s.chars().collect();
99 let mut out = String::with_capacity(s.len() + 8);
100 let mut last: Option<char> = carry.prev;
101 let mut i = 0;
102 while i < chars.len() {
103 let c = chars[i];
104 let next = chars[i + 1..].iter().copied().find(|&n| n != SOFT_HYPHEN);
105 let emitted: Option<char> = match c {
106 '"' if opens_quote(last, carry.double_open)
109 || last == Some(opts.quotes.open_single) =>
110 {
111 carry.double_open = true;
112 Some(opts.quotes.open_double)
113 }
114 '"' => {
115 carry.double_open = false;
118 carry.single_open = false;
119 Some(opts.quotes.close_double)
120 }
121 '\'' => {
122 let after_letter = last.is_some_and(char::is_alphabetic);
127 let in_word = last.is_some_and(char::is_alphanumeric)
128 && next.is_some_and(char::is_alphanumeric);
129 let elision = (opens(last) && next.is_some_and(|n| n.is_ascii_digit()))
130 || (after_letter && !carry.single_open);
131 Some(if in_word || elision {
132 APOSTROPHE
133 } else if opens_quote(last, carry.single_open)
134 || last == Some(opts.quotes.open_double)
135 {
136 carry.single_open = true;
137 opts.quotes.open_single
138 } else {
139 carry.single_open = false;
140 opts.quotes.close_single
141 })
142 }
143 '.' if next == Some('.') && chars.get(i + 2) == Some(&'.') => {
144 i += 2;
145 Some(ELLIPSIS)
146 }
147 '-' if next == Some('-') => {
148 i += if chars.get(i + 2) == Some(&'-') { 3 } else { 2 };
150 while chars.get(i) == Some(&' ') {
151 i += 1;
152 }
153 while out.ends_with(' ') {
154 out.pop();
155 }
156 if opts.german_dashes {
157 let word_before = carry.prev.is_some_and(|p| !p.is_whitespace());
163 if !out.is_empty() || word_before {
164 out.push(NBSP);
165 }
166 out.push(EN_DASH);
167 if chars.get(i).is_some_and(|&n| closes_dash(n, carry)) {
170 last = Some(EN_DASH);
171 } else {
172 out.push(' ');
173 last = Some(' ');
174 }
175 } else {
176 out.push(EM_DASH);
178 last = Some(EM_DASH);
179 }
180 continue;
181 }
182 '-' if opts.german_dashes && last == Some(' ') && next == Some(' ') => {
183 glue_trailing_space(&mut out);
184 Some(EN_DASH)
185 }
186 EN_DASH | EM_DASH
190 if last == Some(' ')
191 && next.is_none_or(|n| n.is_whitespace() || closes_dash(n, carry)) =>
192 {
193 glue_trailing_space(&mut out);
194 Some(c)
195 }
196 _ => Some(c),
197 };
198 if c == SOFT_HYPHEN {
199 out.push(c);
200 } else if let Some(e) = emitted {
201 if is_quote_mark(e) && last.is_some_and(is_quote_mark) {
204 out.push(NNBSP);
205 }
206 out.push(e);
207 last = Some(e);
208 }
209 i += 1;
210 }
211 carry.prev = last;
212 out
213}
214
215pub fn normalize(s: &str, carry: &mut Carry, primary: &str) -> String {
220 normalize_with(s, carry, primary, SmartenOptions::for_lang(primary))
221}
222
223pub fn normalize_with(s: &str, carry: &mut Carry, primary: &str, opts: SmartenOptions) -> String {
226 let s = smarten(s, carry, opts);
227 if primary == "de" { german_nbsp(&s) } else { s }
228}
229
230fn glue_trailing_space(out: &mut String) {
232 if out.ends_with(' ') {
233 out.pop();
234 out.push(NBSP);
235 }
236}
237
238fn is_quote_mark(c: char) -> bool {
240 matches!(
241 c,
242 '\u{2018}'
243 | '\u{2019}'
244 | '\u{201A}'
245 | '\u{201C}'
246 | '\u{201D}'
247 | '\u{201E}'
248 | '\u{00AB}'
249 | '\u{00BB}'
250 | '\u{2039}'
251 | '\u{203A}'
252 )
253}
254
255fn opens(prev: Option<char>) -> bool {
257 match prev {
258 None => true,
259 Some(p) => {
260 p.is_whitespace()
261 || matches!(p, '(' | '[' | '{' | '\u{2014}' | '\u{2013}' | '/')
262 || p == NBSP
263 }
264 }
265}
266
267fn opens_quote(prev: Option<char>, open: bool) -> bool {
271 match prev {
272 Some(EN_DASH | EM_DASH) => !open,
273 p => opens(p),
274 }
275}
276
277fn closes_dash(c: char, carry: &Carry) -> bool {
280 ",.;:)]?!".contains(c) || (c == '"' && carry.double_open) || (c == '\'' && carry.single_open)
281}
282
283pub fn german_nbsp(s: &str) -> String {
286 const ABBR: &[&str] = &[
287 "z. B.", "d. h.", "u. a.", "o. Ä.", "u. Ä.", "v. a.", "s. o.", "s. u.", "z. T.", "i. d. R.",
288 ];
289 let mut out = s.to_string();
290 for a in ABBR {
291 if out.contains(a) {
292 out = out.replace(a, &a.replace(' ', "\u{00A0}"));
293 }
294 }
295 let chars: Vec<char> = out.chars().collect();
297 let mut res = String::with_capacity(out.len());
298 for (idx, &c) in chars.iter().enumerate() {
299 if c == ' ' && idx > 0 {
300 let before = chars[idx - 1];
301 let after = chars.get(idx + 1).copied();
302 let num_then_word = before.is_ascii_digit() && after.is_some_and(char::is_alphabetic);
303 let prefix_then_num = (ends_with(&chars[..idx], "S.")
304 || ends_with(&chars[..idx], "Nr.")
305 || ends_with(&chars[..idx], "§"))
306 && after.is_some_and(|a| a.is_ascii_digit());
307 if num_then_word || prefix_then_num {
308 res.push(NBSP);
309 continue;
310 }
311 }
312 res.push(c);
313 }
314 res
315}
316
317fn ends_with(chars: &[char], suffix: &str) -> bool {
318 let suf: Vec<char> = suffix.chars().collect();
319 chars.len() >= suf.len() && chars[chars.len() - suf.len()..] == suf[..]
320}
321
322pub fn opening_split(s: &str) -> Option<(usize, usize)> {
331 let (i, c) = s.char_indices().find(|(_, c)| c.is_alphanumeric())?;
332 let initial_end = i + c.len_utf8();
333 let after = &s[initial_end..];
334 let (mut words, mut in_word) = (0, false);
335 for (i, c) in after.char_indices() {
336 if c.is_whitespace() {
337 if in_word {
338 words += 1;
339 if words == 3 {
340 return Some((initial_end, initial_end + i));
341 }
342 }
343 in_word = false;
344 } else {
345 in_word = true;
346 }
347 }
348 Some((initial_end, s.len()))
349}
350
351#[cfg(test)]
352mod tests {
353 use super::*;
354
355 fn en(s: &str) -> String {
356 smarten(s, &mut Carry::default(), SmartenOptions::for_lang("en"))
357 }
358 fn de(s: &str) -> String {
359 smarten(s, &mut Carry::default(), SmartenOptions::for_lang("de"))
360 }
361 fn after(c: char) -> Carry {
363 Carry {
364 prev: Some(c),
365 ..Carry::default()
366 }
367 }
368
369 #[test]
370 fn english_quotes_and_apostrophes() {
371 assert_eq!(
372 en(r#""Hello," she said."#),
373 "\u{201C}Hello,\u{201D} she said."
374 );
375 assert_eq!(en("don't"), "don\u{2019}t");
376 assert_eq!(en("'Tis the '90s"), "\u{2018}Tis the \u{2019}90s");
377 assert_eq!(en("He said 'yes'."), "He said \u{2018}yes\u{2019}.");
378 }
379
380 #[test]
381 fn german_quotes() {
382 assert_eq!(de(r#"Sie sagte: "Ja.""#), "Sie sagte: \u{201E}Ja.\u{201C}");
383 assert_eq!(de("Er rief 'hallo'"), "Er rief \u{201A}hallo\u{2018}");
384 assert_eq!(de("geht's"), "geht\u{2019}s");
385 }
386
387 #[test]
388 fn word_final_apostrophe_is_not_a_closing_quote() {
389 assert_eq!(de("ich war' im Stalle"), "ich war\u{2019} im Stalle");
392 assert_eq!(de("Ich stand' die 47"), "Ich stand\u{2019} die 47");
393 assert_eq!(de("Andreas' Hut."), "Andreas\u{2019} Hut.");
394 assert_eq!(de("Das war's."), "Das war\u{2019}s.");
395 assert_eq!(
398 de(
399 "Ich war' im Stalle. Wärme und Geruch kam hervor: 'eine dringende Reise', stand' ich."
400 ),
401 "Ich war\u{2019} im Stalle. Wärme und Geruch kam hervor: \
402 \u{201A}eine dringende Reise\u{2018}, stand\u{2019} ich."
403 );
404 assert_eq!(
405 de("\"Ich war' 'so ist es' gewesen\", sagte er."),
406 "\u{201E}Ich war\u{2019} \u{201A}so ist es\u{2018} gewesen\u{201C}, sagte er."
407 );
408 assert_eq!(
410 de("\"Er rief 'Gut genug!\", und ich war' gewartet."),
411 "\u{201E}Er rief \u{201A}Gut genug!\u{201C}, und ich war\u{2019} gewartet."
412 );
413 let guillemets = SmartenOptions {
414 quotes: Quotes::DE_GUILLEMETS,
415 ..SmartenOptions::for_lang("de")
416 };
417 assert_eq!(
418 smarten(
419 "ich war' im Stalle, 'eine dringende Reise'",
420 &mut Carry::default(),
421 guillemets
422 ),
423 "ich war\u{2019} im Stalle, \u{203A}eine dringende Reise\u{2039}"
424 );
425 assert_eq!(
427 en("I'm goin' home, the boys' hats, 'yes', 'Tis"),
428 "I\u{2019}m goin\u{2019} home, the boys\u{2019} hats, \
429 \u{2018}yes\u{2019}, \u{2018}Tis"
430 );
431 }
432
433 #[test]
434 fn soft_hyphens_are_invisible_to_the_rules() {
435 assert_eq!(
436 en("Peter\u{ad}'s \"Ver\u{ad}legenheit\""),
437 "Peter\u{ad}’s “Ver\u{ad}legenheit”"
438 );
439 assert_eq!(de("\"Ver\u{ad}legenheit\""), "„Ver\u{ad}legenheit“");
440 assert_eq!(en("don\u{ad}'t"), "don\u{ad}’t");
441 }
442
443 #[test]
444 fn dashes() {
445 assert_eq!(en("wait -- what"), "wait\u{2014}what");
446 assert_eq!(en("wait---what"), "wait\u{2014}what");
447 assert_eq!(de("Er kam -- spät"), "Er kam\u{A0}\u{2013} spät");
448 assert_eq!(de("Er kam - spät"), "Er kam\u{A0}\u{2013} spät");
449 assert_eq!(de("Ost-West"), "Ost-West");
450 }
451
452 #[test]
453 fn a_closing_dash_takes_punctuation_directly() {
454 assert_eq!(
456 de("ohne dass je jemand hineinging --, dann ein Kreisverkehr"),
457 "ohne dass je jemand hineinging\u{A0}\u{2013}, dann ein Kreisverkehr"
458 );
459 for p in [",", ".", ";", ":", ")", "]", "?", "!"] {
460 assert_eq!(de(&format!("ja --{p}")), format!("ja\u{A0}\u{2013}{p}"));
461 assert_eq!(de(&format!("ja -- {p}")), format!("ja\u{A0}\u{2013}{p}"));
462 }
463 assert_eq!(de("(liegt -- lag)"), "(liegt\u{A0}\u{2013} lag)");
464 assert_eq!(en("went in--, then"), "went in\u{2014}, then");
466 assert_eq!(en("went in --, then"), "went in\u{2014}, then");
467 assert_eq!(en("(not --)"), "(not\u{2014})");
468 }
469
470 #[test]
471 fn a_dash_after_a_formatting_run_keeps_its_space() {
472 let joined = |runs: &[&str], lang: &str| {
475 let opts = SmartenOptions::for_lang(lang);
476 let mut carry = Carry::default();
477 runs.iter()
478 .map(|r| smarten(r, &mut carry, opts))
479 .collect::<String>()
480 };
481 assert_eq!(
482 joined(&["Er war ", "müde", " -- sehr müde."], "de"),
483 "Er war müde\u{A0}\u{2013} sehr müde."
484 );
485 assert_eq!(
486 joined(&["Er war ", "müde", "-- sehr müde."], "de"),
487 "Er war müde\u{A0}\u{2013} sehr müde."
488 );
489 assert_eq!(
490 joined(&["\"Ich war ", "müde", " --\""], "de"),
491 "\u{201E}Ich war müde\u{A0}\u{2013}\u{201C}"
492 );
493 assert_eq!(
495 joined(&["Er war ", "müde ", "-- sehr müde."], "de"),
496 "Er war müde \u{2013} sehr müde."
497 );
498 assert_eq!(joined(&["-- Ja."], "de"), "\u{2013} Ja.");
500 assert_eq!(
501 smarten("-- Ja.", &mut after(' '), SmartenOptions::for_lang("de")),
502 "\u{2013} Ja."
503 );
504 assert_eq!(
506 joined(&["He was ", "tired", " -- very."], "en"),
507 "He was tired\u{2014}very."
508 );
509 }
510
511 #[test]
512 fn a_quote_after_a_dash_closes_an_open_one() {
513 assert_eq!(
516 de("\"Ich kam --\" sagte er."),
517 "\u{201E}Ich kam\u{A0}\u{2013}\u{201C} sagte er."
518 );
519 assert_eq!(
520 de("\"Aber ich --\""),
521 "\u{201E}Aber ich\u{A0}\u{2013}\u{201C}"
522 );
523 assert_eq!(
524 de("Sie sagt 'nie --', dann geht sie."),
525 "Sie sagt \u{201A}nie\u{A0}\u{2013}\u{2018}, dann geht sie."
526 );
527 assert_eq!(
528 en("\"I was --\" he said."),
529 "\u{201C}I was\u{2014}\u{201D} he said."
530 );
531 assert_eq!(
532 en("'I was--', he said."),
533 "\u{2018}I was\u{2014}\u{2019}, he said."
534 );
535 assert_eq!(
537 de("\"Ich kam \u{2013}\", sagte er."),
538 "\u{201E}Ich kam\u{A0}\u{2013}\u{201C}, sagte er."
539 );
540 assert_eq!(
541 de("hineinging \u{2013}, dann"),
542 "hineinging\u{A0}\u{2013}, dann"
543 );
544 assert_eq!(
545 en("\"I was\u{2014}\" he said."),
546 "\u{201C}I was\u{2014}\u{201D} he said."
547 );
548 assert_eq!(
550 de("\"Ja\", sagt er -- \"nein\"."),
551 "\u{201E}Ja\u{201C}, sagt er\u{A0}\u{2013} \u{201E}nein\u{201C}."
552 );
553 assert_eq!(
554 en("He paused--\"Fine.\""),
555 "He paused\u{2014}\u{201C}Fine.\u{201D}"
556 );
557 assert_eq!(
558 en("\"Yes\"--\"no\""),
559 "\u{201C}Yes\u{201D}\u{2014}\u{201C}no\u{201D}"
560 );
561 assert_eq!(en("the '80s--'90s"), "the \u{2019}80s\u{2014}\u{2019}90s");
562 let opts = SmartenOptions::for_lang("de");
564 let mut carry = Carry::default();
565 let runs = ["\"Ich kam ", "nicht", " mehr --\" sagte er."];
566 let out: String = runs.iter().map(|r| smarten(r, &mut carry, opts)).collect();
567 assert_eq!(
568 out,
569 "\u{201E}Ich kam nicht mehr\u{A0}\u{2013}\u{201C} sagte er."
570 );
571 }
572
573 #[test]
574 fn touching_quotes_stand_a_narrow_space_apart() {
575 assert_eq!(
578 en("\"'How doth the little--'\" and she"),
579 "\u{201C}\u{202F}\u{2018}How doth the little\u{2014}\u{2019}\u{202F}\u{201D} and she"
580 );
581 assert_eq!(
582 en("'\"Yes,\" he said.'"),
583 "\u{2018}\u{202F}\u{201C}Yes,\u{201D} he said.\u{2019}"
584 );
585 assert_eq!(
586 de("\"'Nein', sagte er, 'nie'\""),
587 "\u{201E}\u{202F}\u{201A}Nein\u{2018}, sagte er, \u{201A}nie\u{2018}\u{202F}\u{201C}"
588 );
589 let guillemets = SmartenOptions {
590 quotes: Quotes::DE_GUILLEMETS,
591 german_dashes: true,
592 };
593 assert_eq!(
594 smarten("\"'Nie'\"", &mut Carry::default(), guillemets),
595 "\u{00BB}\u{202F}\u{203A}Nie\u{2039}\u{202F}\u{00AB}"
596 );
597 assert_eq!(
599 en("\u{201C}\u{2018}Hi\u{2019}\u{201D}"),
600 "\u{201C}\u{202F}\u{2018}Hi\u{2019}\u{202F}\u{201D}"
601 );
602 assert_eq!(
603 en("\"the Jones'\""),
604 "\u{201C}the Jones\u{2019}\u{202F}\u{201D}"
605 );
606 let opts = SmartenOptions::for_lang("en");
608 let mut carry = Carry::default();
609 let runs = ["\"", "'Tis", " so.'\""];
610 let out: Vec<String> = runs.iter().map(|r| smarten(r, &mut carry, opts)).collect();
611 assert_eq!(
612 out,
613 [
614 "\u{201C}",
615 "\u{202F}\u{2018}Tis",
616 " so.\u{2019}\u{202F}\u{201D}"
617 ]
618 );
619 assert_eq!(en("\"Hi.\""), "\u{201C}Hi.\u{201D}");
621 }
622
623 #[test]
624 fn spaced_dash_never_starts_a_line() {
625 assert_eq!(de("Er kam \u{2013} spät"), "Er kam\u{A0}\u{2013} spät");
627 assert_eq!(en("wait \u{2014} what"), "wait\u{A0}\u{2014} what");
628 assert_eq!(de("Er kam \u{2013}"), "Er kam\u{A0}\u{2013}");
629 assert_eq!(de("\u{2013} Ja."), "\u{2013} Ja.");
631 assert_eq!(de("1990\u{2013}2000"), "1990\u{2013}2000");
632 assert_eq!(en("wait\u{2014}what"), "wait\u{2014}what");
633 }
634
635 #[test]
636 fn ellipsis() {
637 assert_eq!(en("Well..."), "Well\u{2026}");
638 }
639
640 #[test]
641 fn continuation_uses_prev_char() {
642 assert_eq!(
644 smarten(
645 "\" he said",
646 &mut after('o'),
647 SmartenOptions::for_lang("en")
648 ),
649 "\u{201D} he said"
650 );
651 }
652
653 #[test]
654 fn an_open_single_quote_carries_across_runs() {
655 let opts = SmartenOptions::for_lang("de");
658 let mut carry = Carry::default();
659 let runs = [
660 "Er sagt: 'eine dringende ",
661 "Reise",
662 "', und ich war' Zeit.",
663 ];
664 let out: String = runs.iter().map(|r| smarten(r, &mut carry, opts)).collect();
665 assert_eq!(
666 out,
667 "Er sagt: \u{201A}eine dringende Reise\u{2018}, und ich war\u{2019} Zeit."
668 );
669 assert_eq!(carry, after('.'));
670 }
671
672 #[test]
673 fn normalize_is_smarten_then_german_spacing() {
674 assert_eq!(
675 normalize("\"z. B. 10 km\" -- ja", &mut Carry::default(), "de"),
676 "\u{201E}z.\u{A0}B. 10\u{A0}km\u{201C}\u{A0}\u{2013} ja"
677 );
678 assert_eq!(
679 normalize("z. B. 10 km", &mut Carry::default(), "en"),
680 "z. B. 10 km"
681 );
682 assert_eq!(
683 normalize("\" he said", &mut after('o'), "en"),
684 "\u{201D} he said"
685 );
686 }
687
688 #[test]
689 fn german_nbsp_rules() {
690 assert_eq!(german_nbsp("z. B. 10 km"), "z.\u{A0}B. 10\u{A0}km");
691 assert_eq!(german_nbsp("siehe S. 12"), "siehe S.\u{A0}12");
692 }
693
694 #[test]
695 fn opening_splits() {
696 let split = |s: &'static str| opening_split(s).map(|(a, b)| (&s[..a], &s[a..b]));
697 assert_eq!(
698 split("It is a truth universally acknowledged"),
699 Some(("I", "t is a"))
700 );
701 assert_eq!(
703 split("\"Wer kann das\", sagte sie"),
704 Some(("\"W", "er kann das\","))
705 );
706 assert_eq!(
707 split("\u{201E}Wer kann das\u{201C}, sagte sie"),
708 Some(("\u{201E}W", "er kann das\u{201C},"))
709 );
710 assert_eq!(
711 split("\u{BB}Ja\u{AB}, sagte er"),
712 Some(("\u{BB}J", "a\u{AB}, sagte er"))
713 );
714 assert_eq!(
716 split("Don't keep coughing so, Kitty"),
717 Some(("D", "on't keep coughing"))
718 );
719 assert_eq!(split("'Tis the season"), Some(("'T", "is the season")));
720 assert_eq!(split("1984 was a year"), Some(("1", "984 was a")));
722 assert_eq!(
724 split("A single man of large fortune"),
725 Some(("A", " single man of"))
726 );
727 assert_eq!(split("I"), Some(("I", "")));
728 assert_eq!(split("So \u{2013} he said"), Some(("S", "o \u{2013} he")));
730 assert_eq!(split(" Indented start"), Some((" I", "ndented start")));
732 assert_eq!(split("Short."), Some(("S", "hort.")));
733 for none in ["", " ", "\u{2014}", "* * *", "\u{201E}\u{201C}"] {
735 assert_eq!(opening_split(none), None, "{none:?}");
736 }
737 }
738
739 #[test]
740 fn normalize_takes_smarten_options() {
741 let opts = SmartenOptions {
742 quotes: Quotes::DE_GUILLEMETS,
743 ..SmartenOptions::for_lang("de")
744 };
745 let mut carry = Carry::default();
746 assert_eq!(
747 normalize_with("\"z. B. 10 km\" -- ja", &mut carry, "de", opts),
748 "\u{BB}z.\u{A0}B. 10\u{A0}km\u{AB}\u{A0}\u{2013} ja"
749 );
750 }
751}