typst_library/text/
smartquote.rs

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