From 063e9afccf74201e0d4a8041b48a7a5028e905c3 Mon Sep 17 00:00:00 2001 From: tingerrr Date: Mon, 25 Sep 2023 16:19:22 +0200 Subject: [PATCH] Add custom smart quotes (#2209) --- crates/typst-library/src/layout/par.rs | 4 +- crates/typst-library/src/text/quotes.rs | 156 +++++++++++++++++++++--- crates/typst/src/geom/smart.rs | 30 +++++ tests/ref/text/smartquotes.png | Bin 0 -> 7843 bytes tests/typ/text/smartquotes.typ | 29 +++++ 5 files changed, 203 insertions(+), 16 deletions(-) create mode 100644 tests/ref/text/smartquotes.png create mode 100644 tests/typ/text/smartquotes.typ 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 0000000000000000000000000000000000000000..a6a8cbb5646e1b44327884d15a970691fbcddcbd GIT binary patch literal 7843 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