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 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        // An open single quote still closes after a letter, also after an
396        // elision in the same paragraph.
397        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        // A closing double quote closes a single quote left open inside it.
409        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        // English closes single quotes with ’ anyway: nothing changes.
426        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        // 05-6: no space between the Gedankenstrich and a comma after it.
455        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        // English dashes are closed anyway.
465        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        // Er war *müde* -- sehr müde: the word before the dash is in the run
473        // before, and the space between them must not get lost.
474        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        // A space that ends the run before stays as it is, not doubled.
494        assert_eq!(
495            joined(&["Er war ", "müde ", "-- sehr müde."], "de"),
496            "Er war müde \u{2013} sehr müde."
497        );
498        // A dialogue dash at the start or after a line break is not glued.
499        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        // English dashes are closed.
505        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        // Speech that breaks off: the quote after the dash closes it, with
514        // no space between them.
515        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        // Typed dashes too, glued to the word before them like the others.
536        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        // With no quote of its kind open, a quote after a dash opens one.
549        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        // The open quote carries across formatting runs.
563        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        // A quote inside a quote at its start or end: the two marks get a
576        // narrow no-break space, and the inner one opens after the outer.
577        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        // Typed curly quotes, and an apostrophe before a closing quote.
598        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        // Across formatting runs: the space starts the second run.
607        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        // One quote alone is untouched.
620        assert_eq!(en("\"Hi.\""), "\u{201C}Hi.\u{201D}");
621    }
622
623    #[test]
624    fn spaced_dash_never_starts_a_line() {
625        // Typed (or autocorrected) dashes get the same no-break space.
626        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        // Unspaced dashes and dialogue dashes at the start are untouched.
630        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        // Fragment after an italic run ending in a letter: quote closes.
643        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        // 'eine dringende *Reise*', und ich war' …: the quote closes after
656        // the italic run, the elision after it stays an apostrophe.
657        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        // Opening quotes go with the initial; German and guillemets alike.
702        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        // An apostrophe stays in the lead-in, a leading one in the initial.
715        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        // A digit is an initial.
721        assert_eq!(split("1984 was a year"), Some(("1", "984 was a")));
722        // One-letter words: the initial is the whole first word.
723        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        // A lone dash counts as a word.
729        assert_eq!(split("So \u{2013} he said"), Some(("S", "o \u{2013} he")));
730        // Leading whitespace and fewer than three words.
731        assert_eq!(split("  Indented start"), Some(("  I", "ndented start")));
732        assert_eq!(split("Short."), Some(("S", "hort.")));
733        // Nothing to split.
734        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}