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