mirror of
https://github.com/typst/typst
synced 2025-05-14 17:15:28 +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>() {
|
} else if let Some(elem) = child.to::<SmartquoteElem>() {
|
||||||
let prev = full.len();
|
let prev = full.len();
|
||||||
if SmartquoteElem::enabled_in(styles) {
|
if SmartquoteElem::enabled_in(styles) {
|
||||||
|
let quotes = SmartquoteElem::quotes_in(styles);
|
||||||
let lang = TextElem::lang_in(styles);
|
let lang = TextElem::lang_in(styles);
|
||||||
let region = TextElem::region_in(styles);
|
let region = TextElem::region_in(styles);
|
||||||
let quotes = Quotes::from_lang(
|
let quotes = Quotes::new(
|
||||||
|
"es,
|
||||||
lang,
|
lang,
|
||||||
region,
|
region,
|
||||||
SmartquoteElem::alternative_in(styles),
|
SmartquoteElem::alternative_in(styles),
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
use typst::syntax::is_newline;
|
use typst::syntax::is_newline;
|
||||||
|
use unicode_segmentation::UnicodeSegmentation;
|
||||||
|
|
||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
|
|
||||||
@ -42,7 +43,8 @@ pub struct SmartquoteElem {
|
|||||||
|
|
||||||
/// Whether to use alternative quotes.
|
/// 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
|
/// ```example
|
||||||
/// #set text(lang: "de")
|
/// #set text(lang: "de")
|
||||||
@ -52,6 +54,31 @@ pub struct SmartquoteElem {
|
|||||||
/// ```
|
/// ```
|
||||||
#[default(false)]
|
#[default(false)]
|
||||||
pub alternative: bool,
|
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.
|
/// State machine for smart quote substitution.
|
||||||
@ -146,8 +173,8 @@ pub struct Quotes<'s> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl<'s> Quotes<'s> {
|
impl<'s> Quotes<'s> {
|
||||||
/// Create a new `Quotes` struct with the defaults for a language and
|
/// Create a new `Quotes` struct with the given quotes, optionally falling
|
||||||
/// region.
|
/// back to the defaults for a language and region.
|
||||||
///
|
///
|
||||||
/// The language should be specified as an all-lowercase ISO 639-1 code, the
|
/// The language should be specified as an all-lowercase ISO 639-1 code, the
|
||||||
/// region as an all-uppercase ISO 3166-alpha2 code.
|
/// 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
|
/// Hungarian, Polish, Romanian, Japanese, Traditional Chinese, Russian, and
|
||||||
/// Norwegian.
|
/// Norwegian.
|
||||||
///
|
///
|
||||||
/// For unknown languages, the English quotes are used.
|
/// For unknown languages, the English quotes are used as fallback.
|
||||||
pub fn from_lang(lang: Lang, region: Option<Region>, alternative: bool) -> Self {
|
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 region = region.as_ref().map(Region::as_str);
|
||||||
|
|
||||||
|
let default = ("‘", "’", "“", "”");
|
||||||
let low_high = ("‚", "‘", "„", "“");
|
let low_high = ("‚", "‘", "„", "“");
|
||||||
|
|
||||||
let (single_open, single_close, double_open, double_close) = match lang.as_str() {
|
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" | "sk" | "sl" if alternative => ("›", "‹", "»", "«"),
|
||||||
"cs" | "da" | "de" | "et" | "is" | "lt" | "lv" | "sk" | "sl" => low_high,
|
"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}»"),
|
"fr" => ("‹\u{00A0}", "\u{00A0}›", "«\u{00A0}", "\u{00A0}»"),
|
||||||
"fi" | "sv" if alternative => ("’", "’", "»", "»"),
|
"fi" | "sv" if alternative => ("’", "’", "»", "»"),
|
||||||
"bs" | "fi" | "sv" => ("’", "’", "”", "”"),
|
"bs" | "fi" | "sv" => ("’", "’", "”", "”"),
|
||||||
@ -180,9 +213,28 @@ impl<'s> Quotes<'s> {
|
|||||||
"no" | "nb" | "nn" if alternative => low_high,
|
"no" | "nb" | "nn" if alternative => low_high,
|
||||||
"ru" | "no" | "nb" | "nn" | "ua" => ("’", "’", "«", "»"),
|
"ru" | "no" | "nb" | "nn" | "ua" => ("’", "’", "«", "»"),
|
||||||
_ if lang.dir() == Dir::RTL => ("’", "‘", "”", "“"),
|
_ 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 {
|
Self {
|
||||||
single_open,
|
single_open,
|
||||||
single_close,
|
single_close,
|
||||||
@ -228,14 +280,88 @@ impl<'s> Quotes<'s> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Quotes<'_> {
|
/// An opening and closing quote.
|
||||||
/// Returns the english quotes as default.
|
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
|
||||||
fn default() -> Self {
|
pub struct QuoteSet {
|
||||||
Self {
|
open: EcoString,
|
||||||
single_open: "‘",
|
close: EcoString,
|
||||||
single_close: "’",
|
}
|
||||||
double_open: "“",
|
|
||||||
double_close: "”",
|
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(_))
|
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.
|
/// Returns a reference the contained custom value.
|
||||||
/// If the value is [`Smart::Auto`], `None` is returned.
|
/// If the value is [`Smart::Auto`], `None` is returned.
|
||||||
pub fn as_custom(self) -> Option<T> {
|
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.
|
/// Returns the contained custom value or a provided default value.
|
||||||
pub fn unwrap_or(self, default: T) -> T {
|
pub fn unwrap_or(self, default: T) -> T {
|
||||||
match self {
|
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> {
|
impl<T> Default for Smart<T> {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self::Auto
|
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