Skip to main content

typst_library/text/
smartquote.rs

1use ecow::EcoString;
2use typst_syntax::is_newline;
3use unicode_segmentation::UnicodeSegmentation;
4
5use crate::diag::{HintedStrResult, StrResult, bail};
6use crate::foundations::{
7    Array, Dict, FromValue, Packed, PlainText, Smart, Str, StyleChain, array, cast, dict,
8    elem,
9};
10use crate::layout::Dir;
11use crate::text::{Lang, Region, TextElem};
12
13/// A language-aware quote that reacts to its context.
14///
15/// Automatically turns into an appropriate opening or closing quote based on
16/// the active @text.lang[text language].
17///
18/// = Example <example>
19/// ```example
20/// "This is in quotes."
21///
22/// #set text(lang: "de")
23/// "Das ist in Anführungszeichen."
24///
25/// #set text(lang: "fr")
26/// "C'est entre guillemets."
27/// ```
28///
29/// = Syntax <syntax>
30/// This function also has dedicated syntax: The normal quote characters (`'`
31/// and `"`). Typst automatically makes your quotes smart.
32#[elem(name = "smartquote", PlainText)]
33pub struct SmartQuoteElem {
34    /// Whether this should be a double quote.
35    #[default(true)]
36    pub double: bool,
37
38    /// Whether smart quotes are enabled.
39    ///
40    /// To disable smartness for a single quote, you can also escape it with a
41    /// backslash.
42    ///
43    /// ```example
44    /// #set smartquote(enabled: false)
45    ///
46    /// These are "dumb" quotes.
47    /// ```
48    #[default(true)]
49    pub enabled: bool,
50
51    /// Whether to use alternative quotes.
52    ///
53    /// Does nothing for languages that don't have alternative quotes, or if
54    /// explicit quotes were set.
55    ///
56    /// ```example
57    /// #set text(lang: "de")
58    /// #set smartquote(alternative: true)
59    ///
60    /// "Das ist in anderen Anführungszeichen."
61    /// ```
62    #[default(false)]
63    pub alternative: bool,
64
65    /// The quotes to use.
66    ///
67    /// - When set to `{auto}`, the appropriate single quotes for the
68    ///   @text.lang[text language] will be used. This is the default.
69    /// - Custom quotes can be passed as a string, array, or dictionary of
70    ///   either
71    ///   - @str[string]: a string consisting of two characters containing the
72    ///     opening and closing double quotes (characters here refer to Unicode
73    ///     grapheme clusters)
74    ///   - @array[array]: an array containing the opening and closing double
75    ///     quotes
76    ///   - @dictionary[dictionary]: a dictionary containing the double and
77    ///     single quotes, each specified as either `{auto}`, string, or array
78    ///
79    /// ```example
80    /// #set text(lang: "de")
81    /// 'Das sind normale Anführungszeichen.'
82    ///
83    /// #set smartquote(quotes: "()")
84    /// "Das sind eigene Anführungszeichen."
85    ///
86    /// #set smartquote(quotes: (single: ("[[", "]]"),  double: auto))
87    /// 'Das sind eigene Anführungszeichen.'
88    /// ```
89    pub quotes: Smart<SmartQuoteDict>,
90}
91
92impl PlainText for Packed<SmartQuoteElem> {
93    fn plain_text(&self, text: &mut EcoString) {
94        text.push_str(SmartQuotes::fallback(self.double.as_option().unwrap_or(true)));
95    }
96}
97
98/// A smart quote substitutor with zero lookahead.
99#[derive(Debug, Clone)]
100pub struct SmartQuoter {
101    /// The amount of quotes that have been opened.
102    depth: u8,
103    /// Each bit indicates whether the quote at this nesting depth is a double.
104    /// Maximum supported depth is thus 32.
105    kinds: u32,
106}
107
108impl SmartQuoter {
109    /// Start quoting.
110    pub fn new() -> Self {
111        Self { depth: 0, kinds: 0 }
112    }
113
114    /// Determine which smart quote to substitute given this quoter's nesting
115    /// state and the character immediately preceding the quote.
116    pub fn quote<'a>(
117        &mut self,
118        before: Option<char>,
119        quotes: &SmartQuotes<'a>,
120        double: bool,
121    ) -> &'a str {
122        let opened = self.top();
123        let before = before.unwrap_or(' ');
124
125        // If we are after a number and haven't most recently opened a quote of
126        // this kind, produce a prime. Otherwise, we prefer a closing quote.
127        if before.is_numeric() && opened != Some(double) {
128            return if double { "″" } else { "′" };
129        }
130
131        // If we have a single smart quote, didn't recently open a single
132        // quotation, and are after an alphabetic char or an object (e.g. a
133        // math equation), interpret this as an apostrophe.
134        if !double
135            && opened != Some(false)
136            && (before.is_alphabetic() || before == '\u{FFFC}')
137        {
138            return "’";
139        }
140
141        // If the most recently opened quotation is of this kind and the
142        // previous char does not indicate a nested quotation, close it.
143        if opened == Some(double)
144            && !before.is_whitespace()
145            && !is_newline(before)
146            && !is_opening_bracket(before)
147        {
148            self.pop();
149            return quotes.close(double);
150        }
151
152        // Otherwise, open a new the quotation.
153        self.push(double);
154        quotes.open(double)
155    }
156
157    /// The top of our quotation stack. Returns `Some(double)` for the most
158    /// recently opened quote or `None` if we didn't open one.
159    fn top(&self) -> Option<bool> {
160        self.depth.checked_sub(1).map(|i| (self.kinds >> i) & 1 == 1)
161    }
162
163    /// Push onto the quotation stack.
164    fn push(&mut self, double: bool) {
165        if self.depth < 32 {
166            self.kinds |= (double as u32) << self.depth;
167            self.depth += 1;
168        }
169    }
170
171    /// Pop from the quotation stack.
172    fn pop(&mut self) {
173        self.depth -= 1;
174        self.kinds &= (1 << self.depth) - 1;
175    }
176}
177
178impl Default for SmartQuoter {
179    fn default() -> Self {
180        Self::new()
181    }
182}
183
184/// Whether the character is an opening bracket, parenthesis, or brace.
185fn is_opening_bracket(c: char) -> bool {
186    matches!(c, '(' | '{' | '[')
187}
188
189/// Decides which quotes to substitute smart quotes with.
190pub struct SmartQuotes<'s> {
191    /// The opening single quote.
192    pub single_open: &'s str,
193    /// The closing single quote.
194    pub single_close: &'s str,
195    /// The opening double quote.
196    pub double_open: &'s str,
197    /// The closing double quote.
198    pub double_close: &'s str,
199}
200
201impl<'s> SmartQuotes<'s> {
202    /// Retrieve the smart quotes as configured by the current styles.
203    pub fn get_in(styles: StyleChain<'s>) -> Self {
204        Self::get(
205            styles.get_ref(SmartQuoteElem::quotes),
206            styles.get(TextElem::lang),
207            styles.get(TextElem::region),
208            styles.get(SmartQuoteElem::alternative),
209        )
210    }
211
212    /// Create a new `Quotes` struct with the given quotes, optionally falling
213    /// back to the defaults for a language and region.
214    ///
215    /// The language should be specified as an all-lowercase ISO 639-1 code, the
216    /// region as an all-uppercase ISO 3166-alpha2 code.
217    ///
218    /// Currently, the supported languages are: English, Czech, Danish, German,
219    /// Swiss / Liechtensteinian German, Estonian, Icelandic, Italian, Latin,
220    /// Lithuanian, Latvian, Slovak, Slovenian, Spanish, Bosnian, Finnish,
221    /// Swedish, French, Swiss French, Hungarian, Polish, Romanian, Japanese,
222    /// Traditional Chinese, Russian, Norwegian, Hebrew, Galician and Croatian.
223    ///
224    /// For unknown languages, the English quotes are used as fallback.
225    pub fn get(
226        quotes: &'s Smart<SmartQuoteDict>,
227        lang: Lang,
228        region: Option<Region>,
229        alternative: bool,
230    ) -> Self {
231        let region = region.as_ref().map(Region::as_str);
232
233        let default = ("‘", "’", "“", "”");
234        let low_high = ("‚", "‘", "„", "“");
235
236        let (single_open, single_close, double_open, double_close) = match lang {
237            Lang::GERMAN if matches!(region, Some("CH" | "LI")) => match alternative {
238                false => ("‹", "›", "«", "»"),
239                true => low_high,
240            },
241            Lang::FRENCH if matches!(region, Some("CH")) => match alternative {
242                false => ("‹\u{202F}", "\u{202F}›", "«\u{202F}", "\u{202F}»"),
243                true => default,
244            },
245            Lang::CZECH
246            | Lang::DANISH
247            | Lang::GERMAN
248            | Lang::SLOVAK
249            | Lang::SLOVENIAN
250                if alternative =>
251            {
252                ("›", "‹", "»", "«")
253            }
254            Lang::CZECH
255            | Lang::GERMAN
256            | Lang::ESTONIAN
257            | Lang::ICELANDIC
258            | Lang::LITHUANIAN
259            | Lang::LATVIAN
260            | Lang::SLOVAK
261            | Lang::SLOVENIAN => low_high,
262            Lang::DANISH => ("‘", "’", "“", "”"),
263            Lang::FRENCH if alternative => default,
264            Lang::FRENCH => ("“", "”", "«\u{202F}", "\u{202F}»"),
265            Lang::FINNISH | Lang::SWEDISH if alternative => ("’", "’", "»", "»"),
266            Lang::GALICIAN => ("“", "”", "«", "»"),
267            Lang::BOSNIAN | Lang::FINNISH | Lang::SWEDISH => ("’", "’", "”", "”"),
268            Lang::ITALIAN if alternative => default,
269            Lang::LATIN if alternative => ("“", "”", "«\u{202F}", "\u{202F}»"),
270            Lang::ITALIAN | Lang::LATIN => ("“", "”", "«", "»"),
271            Lang::SPANISH if matches!(region, Some("ES") | None) => ("“", "”", "«", "»"),
272            Lang::HUNGARIAN | Lang::POLISH | Lang::ROMANIAN => ("’", "’", "„", "”"),
273            Lang::NORWEGIAN | Lang::NORWEGIAN_BOKMAL | Lang::NORWEGIAN_NYNORSK
274                if alternative =>
275            {
276                low_high
277            }
278            Lang::NORWEGIAN | Lang::NORWEGIAN_BOKMAL | Lang::NORWEGIAN_NYNORSK => {
279                ("’", "’", "«", "»")
280            }
281            Lang::RUSSIAN => ("„", "“", "«", "»"),
282            Lang::UKRAINIAN => ("“", "”", "«", "»"),
283            Lang::GREEK => ("‘", "’", "«", "»"),
284            Lang::HEBREW => ("’", "’", "”", "”"),
285            Lang::CROATIAN => ("‘", "’", "„", "”"),
286            Lang::BULGARIAN => ("’", "’", "„", "“"),
287            Lang::ARABIC if !alternative => ("’", "‘", "«", "»"),
288            _ if lang.dir() == Dir::RTL => ("’", "‘", "”", "“"),
289            _ => default,
290        };
291
292        fn inner_or_default<'s>(
293            quotes: Smart<&'s SmartQuoteDict>,
294            f: impl FnOnce(&'s SmartQuoteDict) -> Smart<&'s SmartQuoteSet>,
295            default: [&'s str; 2],
296        ) -> [&'s str; 2] {
297            match quotes.and_then(f) {
298                Smart::Auto => default,
299                Smart::Custom(SmartQuoteSet { open, close }) => {
300                    [open, close].map(|s| s.as_str())
301                }
302            }
303        }
304
305        let quotes = quotes.as_ref();
306        let [single_open, single_close] =
307            inner_or_default(quotes, |q| q.single.as_ref(), [single_open, single_close]);
308        let [double_open, double_close] =
309            inner_or_default(quotes, |q| q.double.as_ref(), [double_open, double_close]);
310
311        Self {
312            single_open,
313            single_close,
314            double_open,
315            double_close,
316        }
317    }
318
319    /// The opening quote.
320    pub fn open(&self, double: bool) -> &'s str {
321        if double { self.double_open } else { self.single_open }
322    }
323
324    /// The closing quote.
325    pub fn close(&self, double: bool) -> &'s str {
326        if double { self.double_close } else { self.single_close }
327    }
328
329    /// Get the fallback "dumb" quotes for when smart quotes are disabled.
330    pub fn fallback(double: bool) -> &'static str {
331        if double { "\"" } else { "'" }
332    }
333}
334
335/// An opening and closing quote.
336#[derive(Debug, Clone, Eq, PartialEq, Hash)]
337pub struct SmartQuoteSet {
338    open: EcoString,
339    close: EcoString,
340}
341
342cast! {
343    SmartQuoteSet,
344    self => array![self.open, self.close].into_value(),
345    value: Array => {
346        let [open, close] = array_to_set(value)?;
347        Self { open, close }
348    },
349    value: Str => {
350        let [open, close] = str_to_set(value.as_str())?;
351        Self { open, close }
352    },
353}
354
355fn str_to_set(value: &str) -> StrResult<[EcoString; 2]> {
356    let mut iter = value.graphemes(true);
357    match (iter.next(), iter.next(), iter.next()) {
358        (Some(open), Some(close), None) => Ok([open.into(), close.into()]),
359        _ => {
360            let count = value.graphemes(true).count();
361            bail!(
362                "expected 2 characters, found {count} character{}",
363                if count > 1 { "s" } else { "" },
364            );
365        }
366    }
367}
368
369fn array_to_set(value: Array) -> HintedStrResult<[EcoString; 2]> {
370    let value = value.as_slice();
371    if value.len() != 2 {
372        bail!(
373            "expected 2 quotes, found {} quote{}",
374            value.len(),
375            if value.len() > 1 { "s" } else { "" },
376        );
377    }
378
379    let open: EcoString = value[0].clone().cast()?;
380    let close: EcoString = value[1].clone().cast()?;
381
382    Ok([open, close])
383}
384
385/// A dict of single and double quotes.
386#[derive(Debug, Clone, Eq, PartialEq, Hash)]
387pub struct SmartQuoteDict {
388    double: Smart<SmartQuoteSet>,
389    single: Smart<SmartQuoteSet>,
390}
391
392cast! {
393    SmartQuoteDict,
394    self => dict! { "double" => self.double, "single" => self.single }.into_value(),
395    mut value: Dict => {
396        let keys = ["double", "single"];
397
398        let double = value
399            .take("double")
400            .ok()
401            .map(FromValue::from_value)
402            .transpose()?
403            .unwrap_or(Smart::Auto);
404        let single = value
405            .take("single")
406            .ok()
407            .map(FromValue::from_value)
408            .transpose()?
409            .unwrap_or(Smart::Auto);
410
411        value.finish(&keys)?;
412
413        Self { single, double }
414    },
415    value: SmartQuoteSet => Self {
416        double: Smart::Custom(value),
417        single: Smart::Auto,
418    },
419}