Skip to main content

setzer_model/
text.rs

1//! Language-aware text normalization shared by print and EPUB output.
2//!
3//! Applied at render time, never stored in the model, so the author's text
4//! stays as typed and changing the book language re-quotes everything.
5
6/// Quotation style for a language.
7#[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    /// “English” ‘style’.
17    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    /// „Deutsche“ ‚Anführungszeichen‘.
24    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    /// »Guillemets«, as preferred by many German publishers.
31    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}';
48/// Narrow no-break space, between two quote marks that would touch.
49const NNBSP: char = '\u{202F}';
50const EN_DASH: char = '\u{2013}';
51const EM_DASH: char = '\u{2014}';
52const ELLIPSIS: char = '\u{2026}';
53/// The author's own hyphenation point: invisible, so the rules look
54/// through it (`Peter\u{ad}'s` has an apostrophe).
55const SOFT_HYPHEN: char = '\u{00AD}';
56
57/// Options for [`smarten`].
58#[derive(Debug, Clone, Copy)]
59pub struct SmartenOptions {
60    pub quotes: Quotes,
61    /// German style: ` - ` and `--` become a spaced en dash (Gedankenstrich).
62    /// English style: `--`/`---` become a closed em dash.
63    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/// What [`smarten`] carries from one run of a paragraph to the next (the
76/// text between formatting changes), so quotes after a change open and
77/// close correctly. Each paragraph, title or caption starts from
78/// `Carry::default()`.
79#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
80pub struct Carry {
81    /// The last character written, which decides whether a quote opens or
82    /// closes; `None` at the start.
83    pub prev: Option<char>,
84    /// Whether a single quote is open: a `'` after a letter or a dash then
85    /// closes it; otherwise it is an elision (German "hab'", "Andreas'") or,
86    /// after a dash, opens one.
87    pub single_open: bool,
88    /// Whether a double quote is open: a `"` right after a dash then closes
89    /// it („Ich kam –“); otherwise it opens one (He paused—“Fine.”).
90    pub double_open: bool,
91}
92
93/// Convert typewriter punctuation to typographic punctuation.
94///
95/// `carry` holds what the runs of the paragraph before `s` left (see
96/// [`Carry`]) and is updated for the next one.
97pub 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            // A quote right after the opening quote of the other kind opens
107            // too ("'Hello,' she said" → “‘Hello,’ she said”).
108            '"' 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                // Single quotes nest inside double ones: one left open
116                // (a typo) ends here and cannot swallow a later elision.
117                carry.double_open = false;
118                carry.single_open = false;
119                Some(opts.quotes.close_double)
120            }
121            '\'' => {
122                // Apostrophe inside a word (don't, Peter's), before a digit
123                // ('90s) or after a letter while no single quote is open
124                // (hab', Andreas'); otherwise a single quote. English closes
125                // with ’ either way; German needs the difference (‘ or ‹).
126                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                // `--` or `---`: skip the dashes and any spaces around them.
149                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                    // Gedankenstrich: spaced en dash, glued to the word
158                    // before it so a line never starts with the dash. That
159                    // word may end the run before (Er war *müde* -- sehr);
160                    // a space ending it stays. At the start of the paragraph
161                    // or a line the dash opens dialogue and stands alone.
162                    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                    // A closing dash takes the punctuation or quote after
168                    // it directly: "hineinging –, dann", „Ich kam –“.
169                    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                    // English: closed em dash.
177                    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            // A dash typed with spaces around it (Word autocorrects ` - `
187            // to ` – `), or closing before punctuation or a quote, stays on
188            // the line of the word before it.
189            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            // Two quote marks never touch: ’” ‘“ „‚ »› and the like stand a
202            // narrow no-break space apart.
203            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
215/// Everything print and EPUB apply to a run of manuscript text: [`smarten`]
216/// for the language, then for German [`german_nbsp`]. `primary` is the
217/// primary language subtag ([`crate::Lang::primary`]); `carry` as for
218/// [`smarten`].
219pub fn normalize(s: &str, carry: &mut Carry, primary: &str) -> String {
220    normalize_with(s, carry, primary, SmartenOptions::for_lang(primary))
221}
222
223/// [`normalize`] with other options than the language's own, e.g.
224/// [`Quotes::DE_GUILLEMETS`] for a German book set with guillemets.
225pub 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
230/// Turn the space just written before a spaced dash into a no-break space.
231fn glue_trailing_space(out: &mut String) {
232    if out.ends_with(' ') {
233        out.pop();
234        out.push(NBSP);
235    }
236}
237
238/// A quotation mark of any style, or the apostrophe.
239fn 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
255/// Whether a quote after `prev` is an opening quote.
256fn 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
267/// Whether a quote after `prev` opens, `open` saying whether a quote of its
268/// kind is open. Right after a dash it can do either, so it closes an open
269/// one („Ich kam –“) and otherwise opens (He paused—“Fine.”).
270fn 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
277/// Whether `c` right after a dash ends with it: punctuation, or a quote that
278/// closes ("hineinging –, dann", „Ich kam –“).
279fn closes_dash(c: char, carry: &Carry) -> bool {
280    ",.;:)]?!".contains(c) || (c == '"' && carry.double_open) || (c == '\'' && carry.single_open)
281}
282
283/// German: non-breaking (thin would be nicer, NBSP is safer across readers)
284/// spaces inside common abbreviations and between numbers and units.
285pub 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    // "10 km", "S. 12", "Nr. 5": number/unit glue.
296    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
322/// Where a chapter's first paragraph splits for a drop cap, as byte offsets
323/// into `s`: `(initial_end, lead_in_end)`. The initial runs up to and
324/// including the first letter or digit (so it carries any opening quote
325/// before it); the lead-in is the rest of that word and two more
326/// whitespace-separated words (a lone dash counts as a word), or all of `s`
327/// if there are fewer. `None` when `s` has no letter or digit. Print and
328/// EPUB both split with this, so they cannot disagree; each decides for
329/// itself what may stand before the letter.
330pub 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    /// A run that follows `c` in its paragraph, no quote open.
362    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        // 05-3: in German the closing single quote is ‘ (or ‹), so an
390        // elision at the end of a word must not take it.
391        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        // An open single quote still closes after a letter, also after an
396        // elision in the same paragraph.
397        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        // A closing double quote closes a single quote left open inside it.
407        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        // English closes single quotes with ’ anyway: nothing changes.
424        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        // 05-6: no space between the Gedankenstrich and a comma after it.
453        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        // English dashes are closed anyway.
463        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        // Er war *müde* -- sehr müde: the word before the dash is in the run
471        // before, and the space between them must not get lost.
472        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        // A space that ends the run before stays as it is, not doubled.
492        assert_eq!(
493            joined(&["Er war ", "müde ", "-- sehr müde."], "de"),
494            "Er war müde \u{2013} sehr müde."
495        );
496        // A dialogue dash at the start or after a line break is not glued.
497        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        // English dashes are closed.
503        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        // Speech that breaks off: the quote after the dash closes it, with
512        // no space between them.
513        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        // Typed dashes too, glued to the word before them like the others.
534        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        // With no quote of its kind open, a quote after a dash opens one.
547        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        // The open quote carries across formatting runs.
561        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        // A quote inside a quote at its start or end: the two marks get a
574        // narrow no-break space, and the inner one opens after the outer.
575        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        // Typed curly quotes, and an apostrophe before a closing quote.
596        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        // Across formatting runs: the space starts the second run.
605        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        // One quote alone is untouched.
618        assert_eq!(en("\"Hi.\""), "\u{201C}Hi.\u{201D}");
619    }
620
621    #[test]
622    fn spaced_dash_never_starts_a_line() {
623        // Typed (or autocorrected) dashes get the same no-break space.
624        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        // Unspaced dashes and dialogue dashes at the start are untouched.
628        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        // Fragment after an italic run ending in a letter: quote closes.
641        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        // 'letzte *Gelegenheit*', und ich hab' …: the quote closes after
654        // the italic run, the elision after it stays an apostrophe.
655        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        // Opening quotes go with the initial; German and guillemets alike.
693        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        // An apostrophe stays in the lead-in, a leading one in the initial.
706        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        // A digit is an initial.
712        assert_eq!(split("1984 was a year"), Some(("1", "984 was a")));
713        // One-letter words: the initial is the whole first word.
714        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        // A lone dash counts as a word.
720        assert_eq!(split("So \u{2013} he said"), Some(("S", "o \u{2013} he")));
721        // Leading whitespace and fewer than three words.
722        assert_eq!(split("  Indented start"), Some(("  I", "ndented start")));
723        assert_eq!(split("Short."), Some(("S", "hort.")));
724        // Nothing to split.
725        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}