typst 0.12.0

A new markup-based typesetting system that is powerful and easy to learn.
Documentation
use ecow::EcoString;
use unicode_segmentation::UnicodeSegmentation;

use crate::diag::{bail, HintedStrResult, StrResult};
use crate::foundations::{
    array, cast, dict, elem, Array, Dict, FromValue, Packed, PlainText, Smart, Str,
};
use crate::layout::Dir;
use crate::syntax::is_newline;
use crate::text::{Lang, Region};

/// A language-aware quote that reacts to its context.
///
/// Automatically turns into an appropriate opening or closing quote based on
/// the active [text language]($text.lang).
///
/// # Example
/// ```example
/// "This is in quotes."
///
/// #set text(lang: "de")
/// "Das ist in Anführungszeichen."
///
/// #set text(lang: "fr")
/// "C'est entre guillemets."
/// ```
///
/// # Syntax
/// This function also has dedicated syntax: The normal quote characters
/// (`'` and `"`). Typst automatically makes your quotes smart.
#[elem(name = "smartquote", PlainText)]
pub struct SmartQuoteElem {
    /// Whether this should be a double quote.
    #[default(true)]
    pub double: bool,

    /// Whether smart quotes are enabled.
    ///
    /// To disable smartness for a single quote, you can also escape it with a
    /// backslash.
    ///
    /// ```example
    /// #set smartquote(enabled: false)
    ///
    /// These are "dumb" quotes.
    /// ```
    #[default(true)]
    pub enabled: bool,

    /// Whether to use alternative quotes.
    ///
    /// Does nothing for languages that don't have alternative quotes, or if
    /// explicit quotes were set.
    ///
    /// ```example
    /// #set text(lang: "de")
    /// #set smartquote(alternative: true)
    ///
    /// "Das ist in anderen Anführungszeichen."
    /// ```
    #[default(false)]
    pub alternative: bool,

    /// The quotes to use.
    ///
    /// - When set to `{auto}`, the appropriate single quotes for the
    ///   [text language]($text.lang) will be used. This is the default.
    /// - Custom quotes can be passed as a string, array, or dictionary of either
    ///   - [string]($str): a string consisting of two characters containing the
    ///     opening and closing double quotes (characters here refer to Unicode
    ///     grapheme clusters)
    ///   - [array]: an array containing the opening and closing double quotes
    ///   - [dictionary]: an array containing the double and single quotes, each
    ///     specified as either `{auto}`, string, or array
    ///
    /// ```example
    /// #set text(lang: "de")
    /// 'Das sind normale Anführungszeichen.'
    ///
    /// #set smartquote(quotes: "()")
    /// "Das sind eigene Anführungszeichen."
    ///
    /// #set smartquote(quotes: (single: ("[[", "]]"),  double: auto))
    /// 'Das sind eigene Anführungszeichen.'
    /// ```
    #[borrowed]
    pub quotes: Smart<SmartQuoteDict>,
}

impl PlainText for Packed<SmartQuoteElem> {
    fn plain_text(&self, text: &mut EcoString) {
        if self.double.unwrap_or(true) {
            text.push_str("\"");
        } else {
            text.push_str("'");
        }
    }
}

/// A smart quote substitutor with zero lookahead.
#[derive(Debug, Clone)]
pub struct SmartQuoter {
    /// The amount of quotes that have been opened.
    depth: u8,
    /// Each bit indicates whether the quote at this nesting depth is a double.
    /// Maximum supported depth is thus 32.
    kinds: u32,
}

impl SmartQuoter {
    /// Start quoting.
    pub fn new() -> Self {
        Self { depth: 0, kinds: 0 }
    }

    /// Determine which smart quote to substitute given this quoter's nesting
    /// state and the character immediately preceding the quote.
    pub fn quote<'a>(
        &mut self,
        before: Option<char>,
        quotes: &SmartQuotes<'a>,
        double: bool,
    ) -> &'a str {
        let opened = self.top();
        let before = before.unwrap_or(' ');

        // If we are after a number and haven't most recently opened a quote of
        // this kind, produce a prime. Otherwise, we prefer a closing quote.
        if before.is_numeric() && opened != Some(double) {
            return if double { "" } else { "" };
        }

        // If we have a single smart quote, didn't recently open a single
        // quotation, and are after an alphabetic char or an object (e.g. a
        // math equation), interpret this as an apostrophe.
        if !double
            && opened != Some(false)
            && (before.is_alphabetic() || before == '\u{FFFC}')
        {
            return "";
        }

        // If the most recently opened quotation is of this kind and the
        // previous char does not indicate a nested quotation, close it.
        if opened == Some(double)
            && !before.is_whitespace()
            && !is_newline(before)
            && !is_opening_bracket(before)
        {
            self.pop();
            return quotes.close(double);
        }

        // Otherwise, open a new the quotation.
        self.push(double);
        quotes.open(double)
    }

    /// The top of our quotation stack. Returns `Some(double)` for the most
    /// recently opened quote or `None` if we didn't open one.
    fn top(&self) -> Option<bool> {
        self.depth.checked_sub(1).map(|i| (self.kinds >> i) & 1 == 1)
    }

    /// Push onto the quotation stack.
    fn push(&mut self, double: bool) {
        if self.depth < 32 {
            self.kinds |= (double as u32) << self.depth;
            self.depth += 1;
        }
    }

    /// Pop from the quotation stack.
    fn pop(&mut self) {
        self.depth -= 1;
        self.kinds &= (1 << self.depth) - 1;
    }
}

impl Default for SmartQuoter {
    fn default() -> Self {
        Self::new()
    }
}

/// Whether the character is an opening bracket, parenthesis, or brace.
fn is_opening_bracket(c: char) -> bool {
    matches!(c, '(' | '{' | '[')
}

/// Decides which quotes to substitute smart quotes with.
pub struct SmartQuotes<'s> {
    /// The opening single quote.
    pub single_open: &'s str,
    /// The closing single quote.
    pub single_close: &'s str,
    /// The opening double quote.
    pub double_open: &'s str,
    /// The closing double quote.
    pub double_close: &'s str,
}

impl<'s> SmartQuotes<'s> {
    /// Create a new `Quotes` struct with the given quotes, optionally falling
    /// back to the defaults for a language and region.
    ///
    /// The language should be specified as an all-lowercase ISO 639-1 code, the
    /// region as an all-uppercase ISO 3166-alpha2 code.
    ///
    /// Currently, the supported languages are: English, Czech, Danish, German,
    /// Swiss / Liechtensteinian German, Estonian, Icelandic, Italian, Latin,
    /// Lithuanian, Latvian, Slovak, Slovenian, Spanish, Bosnian, Finnish,
    /// Swedish, French, Swiss French, Hungarian, Polish, Romanian, Japanese,
    /// Traditional Chinese, Russian, Norwegian, and Hebrew.
    ///
    /// For unknown languages, the English quotes are used as fallback.
    pub fn get(
        quotes: &'s Smart<SmartQuoteDict>,
        lang: Lang,
        region: Option<Region>,
        alternative: bool,
    ) -> Self {
        let region = region.as_ref().map(Region::as_str);

        let default = ("", "", "", "");
        let low_high = ("", "", "", "");

        let (single_open, single_close, double_open, double_close) = match lang.as_str() {
            "de" if matches!(region, Some("CH" | "LI")) => match alternative {
                false => ("", "", "«", "»"),
                true => low_high,
            },
            "fr" if matches!(region, Some("CH")) => match alternative {
                false => ("\u{202F}", "\u{202F}", "«\u{202F}", "\u{202F}»"),
                true => default,
            },
            "cs" | "da" | "de" | "sk" | "sl" if alternative => ("", "", "»", "«"),
            "cs" | "de" | "et" | "is" | "lt" | "lv" | "sk" | "sl" => low_high,
            "da" => ("", "", "", ""),
            "fr" | "ru" if alternative => default,
            "fr" => ("\u{00A0}", "\u{00A0}", "«\u{00A0}", "\u{00A0}»"),
            "fi" | "sv" if alternative => ("", "", "»", "»"),
            "bs" | "fi" | "sv" => ("", "", "", ""),
            "it" if alternative => default,
            "la" if alternative => ("", "", "«\u{202F}", "\u{202F}»"),
            "it" | "la" => ("", "", "«", "»"),
            "es" if matches!(region, Some("ES") | None) => ("", "", "«", "»"),
            "hu" | "pl" | "ro" => ("", "", "", ""),
            "no" | "nb" | "nn" if alternative => low_high,
            "ru" | "no" | "nb" | "nn" | "ua" => ("", "", "«", "»"),
            "gr" => ("", "", "«", "»"),
            "he" => ("", "", "", ""),
            _ if lang.dir() == Dir::RTL => ("", "", "", ""),
            _ => default,
        };

        fn inner_or_default<'s>(
            quotes: Smart<&'s SmartQuoteDict>,
            f: impl FnOnce(&'s SmartQuoteDict) -> Smart<&'s SmartQuoteSet>,
            default: [&'s str; 2],
        ) -> [&'s str; 2] {
            match quotes.and_then(f) {
                Smart::Auto => default,
                Smart::Custom(SmartQuoteSet { open, close }) => {
                    [open, close].map(|s| s.as_str())
                }
            }
        }

        let quotes = quotes.as_ref();
        let [single_open, single_close] =
            inner_or_default(quotes, |q| q.single.as_ref(), [single_open, single_close]);
        let [double_open, double_close] =
            inner_or_default(quotes, |q| q.double.as_ref(), [double_open, double_close]);

        Self {
            single_open,
            single_close,
            double_open,
            double_close,
        }
    }

    /// The opening quote.
    pub fn open(&self, double: bool) -> &'s str {
        if double {
            self.double_open
        } else {
            self.single_open
        }
    }

    /// The closing quote.
    pub fn close(&self, double: bool) -> &'s str {
        if double {
            self.double_close
        } else {
            self.single_close
        }
    }
}

/// An opening and closing quote.
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
pub struct SmartQuoteSet {
    open: EcoString,
    close: EcoString,
}

cast! {
    SmartQuoteSet,
    self => array![self.open, self.close].into_value(),
    value: Array => {
        let [open, close] = array_to_set(value)?;
        Self { open, close }
    },
    value: Str => {
        let [open, close] = str_to_set(value.as_str())?;
        Self { open, close }
    },
}

fn str_to_set(value: &str) -> StrResult<[EcoString; 2]> {
    let mut iter = value.graphemes(true);
    match (iter.next(), iter.next(), iter.next()) {
        (Some(open), Some(close), None) => Ok([open.into(), close.into()]),
        _ => {
            let count = value.graphemes(true).count();
            bail!(
                "expected 2 characters, found {count} character{}",
                if count > 1 { "s" } else { "" }
            );
        }
    }
}

fn array_to_set(value: Array) -> HintedStrResult<[EcoString; 2]> {
    let value = value.as_slice();
    if value.len() != 2 {
        bail!(
            "expected 2 quotes, found {} quote{}",
            value.len(),
            if value.len() > 1 { "s" } else { "" }
        );
    }

    let open: EcoString = value[0].clone().cast()?;
    let close: EcoString = value[1].clone().cast()?;

    Ok([open, close])
}

/// A dict of single and double quotes.
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
pub struct SmartQuoteDict {
    double: Smart<SmartQuoteSet>,
    single: Smart<SmartQuoteSet>,
}

cast! {
    SmartQuoteDict,
    self => dict! { "double" => self.double, "single" => self.single }.into_value(),
    mut value: Dict => {
        let keys = ["double", "single"];

        let double = value
            .take("double")
            .ok()
            .map(FromValue::from_value)
            .transpose()?
            .unwrap_or(Smart::Auto);
        let single = value
            .take("single")
            .ok()
            .map(FromValue::from_value)
            .transpose()?
            .unwrap_or(Smart::Auto);

        value.finish(&keys)?;

        Self { single, double }
    },
    value: SmartQuoteSet => Self {
        double: Smart::Custom(value),
        single: Smart::Auto,
    },
}