mirror of
https://github.com/typst/typst
synced 2025-05-14 04:56:26 +08:00
Add custom smart quotes (#2209)
This commit is contained in:
parent
079ccd5e5b
commit
063e9afccf
@ -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(
|
||||
"es,
|
||||
lang,
|
||||
region,
|
||||
SmartquoteElem::alternative_in(styles),
|
||||
|
@ -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,
|
||||
},
|
||||
}
|
||||
|
@ -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
|
||||
|
BIN
tests/ref/text/smartquotes.png
Normal file
BIN
tests/ref/text/smartquotes.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 7.7 KiB |
29
tests/typ/text/smartquotes.typ
Normal file
29
tests/typ/text/smartquotes.typ
Normal 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))
|
Loading…
x
Reference in New Issue
Block a user