Add custom smart quotes (#2209)

This commit is contained in:
tingerrr 2023-09-25 16:19:22 +02:00 committed by GitHub
parent 079ccd5e5b
commit 063e9afccf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 203 additions and 16 deletions

View file

@ -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),

View file

@ -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,
},
}

View file

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

View file

@ -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))