summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authortingerrr <github@tinger.dev>2023-09-25 16:19:22 +0200
committerGitHub <noreply@github.com>2023-09-25 16:19:22 +0200
commit063e9afccf74201e0d4a8041b48a7a5028e905c3 (patch)
treea7c26180982a131ed4808ee2f2b0f3380ad1b658
parent079ccd5e5b8558b177c8b583948c60d75e3f547f (diff)
Add custom smart quotes (#2209)
-rw-r--r--crates/typst-library/src/layout/par.rs4
-rw-r--r--crates/typst-library/src/text/quotes.rs156
-rw-r--r--crates/typst/src/geom/smart.rs30
-rw-r--r--tests/ref/text/smartquotes.pngbin0 -> 7843 bytes
-rw-r--r--tests/typ/text/smartquotes.typ29
5 files changed, 203 insertions, 16 deletions
diff --git a/crates/typst-library/src/layout/par.rs b/crates/typst-library/src/layout/par.rs
index 01ccf127..82e7e52d 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::<SmartquoteElem>() {
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(
+ &quotes,
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 a47f7ed5..37e664fd 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<QuoteDict>,
}
/// 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<Region>, alternative: bool) -> Self {
+ /// For unknown languages, the English quotes are used as fallback.
+ pub fn new(
+ quotes: &'s Smart<QuoteDict>,
+ 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() {
@@ -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<QuoteSet>,
+ single: Smart<QuoteSet>,
+}
+
+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 2c6e241e..2a21490b 100644
--- a/crates/typst/src/geom/smart.rs
+++ b/crates/typst/src/geom/smart.rs
@@ -22,6 +22,14 @@ impl<T> Smart<T> {
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<T> {
@@ -62,6 +70,18 @@ impl<T> Smart<T> {
}
}
+ /// Retusn `Auto` if `self` is `Auto`, otherwise calls the provided function onthe contained
+ /// value and returns the result.
+ pub fn and_then<F, U>(self, f: F) -> Smart<U>
+ where
+ F: FnOnce(T) -> Smart<U>,
+ {
+ 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<T> Smart<T> {
}
}
+impl<T> Smart<Smart<T>> {
+ /// Removes a single level of nesting, returns `Auto` if the inner or outer value is `Auto`.
+ pub fn flatten(self) -> Smart<T> {
+ match self {
+ Smart::Custom(Smart::Auto) | Smart::Auto => Smart::Auto,
+ Smart::Custom(Smart::Custom(v)) => Smart::Custom(v),
+ }
+ }
+}
+
impl<T> Default for Smart<T> {
fn default() -> Self {
Self::Auto
diff --git a/tests/ref/text/smartquotes.png b/tests/ref/text/smartquotes.png
new file mode 100644
index 00000000..a6a8cbb5
--- /dev/null
+++ b/tests/ref/text/smartquotes.png
Binary files differ
diff --git a/tests/typ/text/smartquotes.typ b/tests/typ/text/smartquotes.typ
new file mode 100644
index 00000000..da31866e
--- /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))