diff --git a/crates/typst-library/src/layout/par.rs b/crates/typst-library/src/layout/par.rs index 01ccf1272..82e7e52dc 100644 --- a/crates/typst-library/src/layout/par.rs +++ b/crates/typst-library/src/layout/par.rs @@ -589,9 +589,11 @@ fn collect<'a>( } else if let Some(elem) = child.to::() { let prev = full.len(); if SmartquoteElem::enabled_in(styles) { + let quotes = SmartquoteElem::quotes_in(styles); let lang = TextElem::lang_in(styles); let region = TextElem::region_in(styles); - let quotes = Quotes::from_lang( + let quotes = Quotes::new( + "es, lang, region, SmartquoteElem::alternative_in(styles), diff --git a/crates/typst-library/src/text/quotes.rs b/crates/typst-library/src/text/quotes.rs index a47f7ed51..37e664fd9 100644 --- a/crates/typst-library/src/text/quotes.rs +++ b/crates/typst-library/src/text/quotes.rs @@ -1,4 +1,5 @@ use typst::syntax::is_newline; +use unicode_segmentation::UnicodeSegmentation; use crate::prelude::*; @@ -42,7 +43,8 @@ pub struct SmartquoteElem { /// Whether to use alternative quotes. /// - /// Does nothing for languages that don't have alternative quotes. + /// Does nothing for languages that don't have alternative quotes, or if + /// explicit quotes were set. /// /// ```example /// #set text(lang: "de") @@ -52,6 +54,31 @@ pub struct SmartquoteElem { /// ``` #[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]($array): an array containing the opening and closing double + /// quotes + /// - [dictionary]($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.' + /// ``` + pub quotes: Smart, } /// State machine for smart quote substitution. @@ -146,8 +173,8 @@ pub struct Quotes<'s> { } impl<'s> Quotes<'s> { - /// Create a new `Quotes` struct with the defaults for a language and - /// region. + /// 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. @@ -158,10 +185,16 @@ impl<'s> Quotes<'s> { /// Hungarian, Polish, Romanian, Japanese, Traditional Chinese, Russian, and /// Norwegian. /// - /// For unknown languages, the English quotes are used. - pub fn from_lang(lang: Lang, region: Option, alternative: bool) -> Self { + /// For unknown languages, the English quotes are used as fallback. + pub fn new( + quotes: &'s Smart, + lang: Lang, + region: Option, + 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() { @@ -171,7 +204,7 @@ impl<'s> Quotes<'s> { }, "cs" | "da" | "de" | "sk" | "sl" if alternative => ("›", "‹", "»", "«"), "cs" | "da" | "de" | "et" | "is" | "lt" | "lv" | "sk" | "sl" => low_high, - "fr" | "ru" if alternative => return Self::default(), + "fr" | "ru" if alternative => default, "fr" => ("‹\u{00A0}", "\u{00A0}›", "«\u{00A0}", "\u{00A0}»"), "fi" | "sv" if alternative => ("’", "’", "»", "»"), "bs" | "fi" | "sv" => ("’", "’", "”", "”"), @@ -180,9 +213,28 @@ impl<'s> Quotes<'s> { "no" | "nb" | "nn" if alternative => low_high, "ru" | "no" | "nb" | "nn" | "ua" => ("’", "’", "«", "»"), _ if lang.dir() == Dir::RTL => ("’", "‘", "”", "“"), - _ => return Self::default(), + _ => default, }; + fn inner_or_default<'s>( + quotes: Smart<&'s QuoteDict>, + f: impl FnOnce(&'s QuoteDict) -> Smart<&'s QuoteSet>, + default: [&'s str; 2], + ) -> [&'s str; 2] { + match quotes.and_then(f) { + Smart::Auto => default, + Smart::Custom(QuoteSet { 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, @@ -228,14 +280,88 @@ impl<'s> Quotes<'s> { } } -impl Default for Quotes<'_> { - /// Returns the english quotes as default. - fn default() -> Self { - Self { - single_open: "‘", - single_close: "’", - double_open: "“", - double_close: "”", +/// An opening and closing quote. +#[derive(Debug, Clone, Eq, PartialEq, Hash)] +pub struct QuoteSet { + open: EcoString, + close: EcoString, +} + +cast! { + QuoteSet, + 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) -> StrResult<[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 QuoteDict { + double: Smart, + single: Smart, +} + +cast! { + QuoteDict, + 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: QuoteSet => Self { + double: Smart::Custom(value), + single: Smart::Auto, + }, +} diff --git a/crates/typst/src/geom/smart.rs b/crates/typst/src/geom/smart.rs index 2c6e241ed..2a21490b4 100644 --- a/crates/typst/src/geom/smart.rs +++ b/crates/typst/src/geom/smart.rs @@ -22,6 +22,14 @@ impl Smart { matches!(self, Self::Custom(_)) } + /// Returns a `Smart<&T>` borrowing the inner `T`. + pub fn as_ref(&self) -> Smart<&T> { + match self { + Smart::Auto => Smart::Auto, + Smart::Custom(v) => Smart::Custom(v), + } + } + /// Returns a reference the contained custom value. /// If the value is [`Smart::Auto`], `None` is returned. pub fn as_custom(self) -> Option { @@ -62,6 +70,18 @@ impl Smart { } } + /// Retusn `Auto` if `self` is `Auto`, otherwise calls the provided function onthe contained + /// value and returns the result. + pub fn and_then(self, f: F) -> Smart + where + F: FnOnce(T) -> Smart, + { + match self { + Smart::Auto => Smart::Auto, + Smart::Custom(x) => f(x), + } + } + /// Returns the contained custom value or a provided default value. pub fn unwrap_or(self, default: T) -> T { match self { @@ -90,6 +110,16 @@ impl Smart { } } +impl Smart> { + /// Removes a single level of nesting, returns `Auto` if the inner or outer value is `Auto`. + pub fn flatten(self) -> Smart { + match self { + Smart::Custom(Smart::Auto) | Smart::Auto => Smart::Auto, + Smart::Custom(Smart::Custom(v)) => Smart::Custom(v), + } + } +} + impl Default for Smart { fn default() -> Self { Self::Auto diff --git a/tests/ref/text/smartquotes.png b/tests/ref/text/smartquotes.png new file mode 100644 index 000000000..a6a8cbb56 Binary files /dev/null and b/tests/ref/text/smartquotes.png differ diff --git a/tests/typ/text/smartquotes.typ b/tests/typ/text/smartquotes.typ new file mode 100644 index 000000000..da31866e9 --- /dev/null +++ b/tests/typ/text/smartquotes.typ @@ -0,0 +1,29 @@ +// Test setting custom smartquotes + +--- +// Use language quotes for missing keys, allow partial reset +#set smartquote(quotes: "«»") +"Double and 'Single' Quotes" + +#set smartquote(quotes: (double: auto, single: "«»")) +"Double and 'Single' Quotes" + +--- +// Allow 2 graphemes +#set smartquote(quotes: "a\u{0301}a\u{0301}") +"Double and 'Single' Quotes" + +#set smartquote(quotes: (single: "a\u{0301}a\u{0301}")) +"Double and 'Single' Quotes" + +--- +// Error: 25-28 expected 2 characters, found 1 character +#set smartquote(quotes: "'") + +--- +// Error: 25-35 expected 2 quotes, found 4 quotes +#set smartquote(quotes: ("'",) * 4) + +--- +// Error: 25-45 expected 2 quotes, found 4 quotes +#set smartquote(quotes: (single: ("'",) * 4))