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 hab' kein Auto"), "ich hab\u{2019} kein Auto");
392 assert_eq!(de("Ich fahr' die 47"), "Ich fahr\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("Ich hab' kein Auto. Erst wenn einer sagt: 'letzte Gelegenheit', fahr' ich."),
399 "Ich hab\u{2019} kein Auto. Erst wenn einer sagt: \
400 \u{201A}letzte Gelegenheit\u{2018}, fahr\u{2019} ich."
401 );
402 assert_eq!(
403 de("\"Ich hab' 'nein' gesagt\", sagte er."),
404 "\u{201E}Ich hab\u{2019} \u{201A}nein\u{2018} gesagt\u{201C}, sagte er."
405 );
406 assert_eq!(
408 de("\"Er rief 'Halt!\", und ich hab' gewartet."),
409 "\u{201E}Er rief \u{201A}Halt!\u{201C}, und ich hab\u{2019} gewartet."
410 );
411 let guillemets = SmartenOptions {
412 quotes: Quotes::DE_GUILLEMETS,
413 ..SmartenOptions::for_lang("de")
414 };
415 assert_eq!(
416 smarten(
417 "ich hab' kein Auto, 'letzte Gelegenheit'",
418 &mut Carry::default(),
419 guillemets
420 ),
421 "ich hab\u{2019} kein Auto, \u{203A}letzte Gelegenheit\u{2039}"
422 );
423 assert_eq!(
425 en("I'm goin' home, the boys' hats, 'yes', 'Tis"),
426 "I\u{2019}m goin\u{2019} home, the boys\u{2019} hats, \
427 \u{2018}yes\u{2019}, \u{2018}Tis"
428 );
429 }
430
431 #[test]
432 fn soft_hyphens_are_invisible_to_the_rules() {
433 assert_eq!(
434 en("Peter\u{ad}'s \"Ver\u{ad}legenheit\""),
435 "Peter\u{ad}’s “Ver\u{ad}legenheit”"
436 );
437 assert_eq!(de("\"Ver\u{ad}legenheit\""), "„Ver\u{ad}legenheit“");
438 assert_eq!(en("don\u{ad}'t"), "don\u{ad}’t");
439 }
440
441 #[test]
442 fn dashes() {
443 assert_eq!(en("wait -- what"), "wait\u{2014}what");
444 assert_eq!(en("wait---what"), "wait\u{2014}what");
445 assert_eq!(de("Er kam -- spät"), "Er kam\u{A0}\u{2013} spät");
446 assert_eq!(de("Er kam - spät"), "Er kam\u{A0}\u{2013} spät");
447 assert_eq!(de("Ost-West"), "Ost-West");
448 }
449
450 #[test]
451 fn a_closing_dash_takes_punctuation_directly() {
452 assert_eq!(
454 de("ohne dass je jemand hineinging --, dann ein Kreisverkehr"),
455 "ohne dass je jemand hineinging\u{A0}\u{2013}, dann ein Kreisverkehr"
456 );
457 for p in [",", ".", ";", ":", ")", "]", "?", "!"] {
458 assert_eq!(de(&format!("ja --{p}")), format!("ja\u{A0}\u{2013}{p}"));
459 assert_eq!(de(&format!("ja -- {p}")), format!("ja\u{A0}\u{2013}{p}"));
460 }
461 assert_eq!(de("(liegt -- lag)"), "(liegt\u{A0}\u{2013} lag)");
462 assert_eq!(en("went in--, then"), "went in\u{2014}, then");
464 assert_eq!(en("went in --, then"), "went in\u{2014}, then");
465 assert_eq!(en("(not --)"), "(not\u{2014})");
466 }
467
468 #[test]
469 fn a_dash_after_a_formatting_run_keeps_its_space() {
470 let joined = |runs: &[&str], lang: &str| {
473 let opts = SmartenOptions::for_lang(lang);
474 let mut carry = Carry::default();
475 runs.iter()
476 .map(|r| smarten(r, &mut carry, opts))
477 .collect::<String>()
478 };
479 assert_eq!(
480 joined(&["Er war ", "müde", " -- sehr müde."], "de"),
481 "Er war müde\u{A0}\u{2013} sehr müde."
482 );
483 assert_eq!(
484 joined(&["Er war ", "müde", "-- sehr müde."], "de"),
485 "Er war müde\u{A0}\u{2013} sehr müde."
486 );
487 assert_eq!(
488 joined(&["\"Ich war ", "müde", " --\""], "de"),
489 "\u{201E}Ich war müde\u{A0}\u{2013}\u{201C}"
490 );
491 assert_eq!(
493 joined(&["Er war ", "müde ", "-- sehr müde."], "de"),
494 "Er war müde \u{2013} sehr müde."
495 );
496 assert_eq!(joined(&["-- Ja."], "de"), "\u{2013} Ja.");
498 assert_eq!(
499 smarten("-- Ja.", &mut after(' '), SmartenOptions::for_lang("de")),
500 "\u{2013} Ja."
501 );
502 assert_eq!(
504 joined(&["He was ", "tired", " -- very."], "en"),
505 "He was tired\u{2014}very."
506 );
507 }
508
509 #[test]
510 fn a_quote_after_a_dash_closes_an_open_one() {
511 assert_eq!(
514 de("\"Ich kam --\" sagte er."),
515 "\u{201E}Ich kam\u{A0}\u{2013}\u{201C} sagte er."
516 );
517 assert_eq!(
518 de("\"Aber ich --\""),
519 "\u{201E}Aber ich\u{A0}\u{2013}\u{201C}"
520 );
521 assert_eq!(
522 de("Sie sagt 'nie --', dann geht sie."),
523 "Sie sagt \u{201A}nie\u{A0}\u{2013}\u{2018}, dann geht sie."
524 );
525 assert_eq!(
526 en("\"I was --\" he said."),
527 "\u{201C}I was\u{2014}\u{201D} he said."
528 );
529 assert_eq!(
530 en("'I was--', he said."),
531 "\u{2018}I was\u{2014}\u{2019}, he said."
532 );
533 assert_eq!(
535 de("\"Ich kam \u{2013}\", sagte er."),
536 "\u{201E}Ich kam\u{A0}\u{2013}\u{201C}, sagte er."
537 );
538 assert_eq!(
539 de("hineinging \u{2013}, dann"),
540 "hineinging\u{A0}\u{2013}, dann"
541 );
542 assert_eq!(
543 en("\"I was\u{2014}\" he said."),
544 "\u{201C}I was\u{2014}\u{201D} he said."
545 );
546 assert_eq!(
548 de("\"Ja\", sagt er -- \"nein\"."),
549 "\u{201E}Ja\u{201C}, sagt er\u{A0}\u{2013} \u{201E}nein\u{201C}."
550 );
551 assert_eq!(
552 en("He paused--\"Fine.\""),
553 "He paused\u{2014}\u{201C}Fine.\u{201D}"
554 );
555 assert_eq!(
556 en("\"Yes\"--\"no\""),
557 "\u{201C}Yes\u{201D}\u{2014}\u{201C}no\u{201D}"
558 );
559 assert_eq!(en("the '80s--'90s"), "the \u{2019}80s\u{2014}\u{2019}90s");
560 let opts = SmartenOptions::for_lang("de");
562 let mut carry = Carry::default();
563 let runs = ["\"Ich kam ", "nicht", " mehr --\" sagte er."];
564 let out: String = runs.iter().map(|r| smarten(r, &mut carry, opts)).collect();
565 assert_eq!(
566 out,
567 "\u{201E}Ich kam nicht mehr\u{A0}\u{2013}\u{201C} sagte er."
568 );
569 }
570
571 #[test]
572 fn touching_quotes_stand_a_narrow_space_apart() {
573 assert_eq!(
576 en("\"'How doth the little--'\" and she"),
577 "\u{201C}\u{202F}\u{2018}How doth the little\u{2014}\u{2019}\u{202F}\u{201D} and she"
578 );
579 assert_eq!(
580 en("'\"Yes,\" he said.'"),
581 "\u{2018}\u{202F}\u{201C}Yes,\u{201D} he said.\u{2019}"
582 );
583 assert_eq!(
584 de("\"'Nein', sagte er, 'nie'\""),
585 "\u{201E}\u{202F}\u{201A}Nein\u{2018}, sagte er, \u{201A}nie\u{2018}\u{202F}\u{201C}"
586 );
587 let guillemets = SmartenOptions {
588 quotes: Quotes::DE_GUILLEMETS,
589 german_dashes: true,
590 };
591 assert_eq!(
592 smarten("\"'Nie'\"", &mut Carry::default(), guillemets),
593 "\u{00BB}\u{202F}\u{203A}Nie\u{2039}\u{202F}\u{00AB}"
594 );
595 assert_eq!(
597 en("\u{201C}\u{2018}Hi\u{2019}\u{201D}"),
598 "\u{201C}\u{202F}\u{2018}Hi\u{2019}\u{202F}\u{201D}"
599 );
600 assert_eq!(
601 en("\"the Jones'\""),
602 "\u{201C}the Jones\u{2019}\u{202F}\u{201D}"
603 );
604 let opts = SmartenOptions::for_lang("en");
606 let mut carry = Carry::default();
607 let runs = ["\"", "'Tis", " so.'\""];
608 let out: Vec<String> = runs.iter().map(|r| smarten(r, &mut carry, opts)).collect();
609 assert_eq!(
610 out,
611 [
612 "\u{201C}",
613 "\u{202F}\u{2018}Tis",
614 " so.\u{2019}\u{202F}\u{201D}"
615 ]
616 );
617 assert_eq!(en("\"Hi.\""), "\u{201C}Hi.\u{201D}");
619 }
620
621 #[test]
622 fn spaced_dash_never_starts_a_line() {
623 assert_eq!(de("Er kam \u{2013} spät"), "Er kam\u{A0}\u{2013} spät");
625 assert_eq!(en("wait \u{2014} what"), "wait\u{A0}\u{2014} what");
626 assert_eq!(de("Er kam \u{2013}"), "Er kam\u{A0}\u{2013}");
627 assert_eq!(de("\u{2013} Ja."), "\u{2013} Ja.");
629 assert_eq!(de("1990\u{2013}2000"), "1990\u{2013}2000");
630 assert_eq!(en("wait\u{2014}what"), "wait\u{2014}what");
631 }
632
633 #[test]
634 fn ellipsis() {
635 assert_eq!(en("Well..."), "Well\u{2026}");
636 }
637
638 #[test]
639 fn continuation_uses_prev_char() {
640 assert_eq!(
642 smarten(
643 "\" he said",
644 &mut after('o'),
645 SmartenOptions::for_lang("en")
646 ),
647 "\u{201D} he said"
648 );
649 }
650
651 #[test]
652 fn an_open_single_quote_carries_across_runs() {
653 let opts = SmartenOptions::for_lang("de");
656 let mut carry = Carry::default();
657 let runs = ["Er sagt: 'letzte ", "Gelegenheit", "', und ich hab' Zeit."];
658 let out: String = runs.iter().map(|r| smarten(r, &mut carry, opts)).collect();
659 assert_eq!(
660 out,
661 "Er sagt: \u{201A}letzte Gelegenheit\u{2018}, und ich hab\u{2019} Zeit."
662 );
663 assert_eq!(carry, after('.'));
664 }
665
666 #[test]
667 fn normalize_is_smarten_then_german_spacing() {
668 assert_eq!(
669 normalize("\"z. B. 10 km\" -- ja", &mut Carry::default(), "de"),
670 "\u{201E}z.\u{A0}B. 10\u{A0}km\u{201C}\u{A0}\u{2013} ja"
671 );
672 assert_eq!(
673 normalize("z. B. 10 km", &mut Carry::default(), "en"),
674 "z. B. 10 km"
675 );
676 assert_eq!(
677 normalize("\" he said", &mut after('o'), "en"),
678 "\u{201D} he said"
679 );
680 }
681
682 #[test]
683 fn german_nbsp_rules() {
684 assert_eq!(german_nbsp("z. B. 10 km"), "z.\u{A0}B. 10\u{A0}km");
685 assert_eq!(german_nbsp("siehe S. 12"), "siehe S.\u{A0}12");
686 }
687
688 #[test]
689 fn opening_splits() {
690 let split = |s: &'static str| opening_split(s).map(|(a, b)| (&s[..a], &s[a..b]));
691 assert_eq!(split("It was the best of times"), Some(("I", "t was the")));
692 assert_eq!(
694 split("\"Es war einmal\", sagte sie"),
695 Some(("\"E", "s war einmal\","))
696 );
697 assert_eq!(
698 split("\u{201E}Es war einmal\u{201C}, sagte sie"),
699 Some(("\u{201E}E", "s war einmal\u{201C},"))
700 );
701 assert_eq!(
702 split("\u{BB}Ja\u{AB}, sagte er"),
703 Some(("\u{BB}J", "a\u{AB}, sagte er"))
704 );
705 assert_eq!(
707 split("Don't go there, he said"),
708 Some(("D", "on't go there,"))
709 );
710 assert_eq!(split("'Tis the season"), Some(("'T", "is the season")));
711 assert_eq!(split("1984 was a year"), Some(("1", "984 was a")));
713 assert_eq!(
715 split("A man walked into a bar"),
716 Some(("A", " man walked into"))
717 );
718 assert_eq!(split("I"), Some(("I", "")));
719 assert_eq!(split("So \u{2013} he said"), Some(("S", "o \u{2013} he")));
721 assert_eq!(split(" Indented start"), Some((" I", "ndented start")));
723 assert_eq!(split("Short."), Some(("S", "hort.")));
724 for none in ["", " ", "\u{2014}", "* * *", "\u{201E}\u{201C}"] {
726 assert_eq!(opening_split(none), None, "{none:?}");
727 }
728 }
729
730 #[test]
731 fn normalize_takes_smarten_options() {
732 let opts = SmartenOptions {
733 quotes: Quotes::DE_GUILLEMETS,
734 ..SmartenOptions::for_lang("de")
735 };
736 let mut carry = Carry::default();
737 assert_eq!(
738 normalize_with("\"z. B. 10 km\" -- ja", &mut carry, "de", opts),
739 "\u{BB}z.\u{A0}B. 10\u{A0}km\u{AB}\u{A0}\u{2013} ja"
740 );
741 }
742}