1use ecow::EcoString;
2use typst_syntax::is_newline;
3use unicode_segmentation::UnicodeSegmentation;
4
5use crate::diag::{HintedStrResult, StrResult, bail};
6use crate::foundations::{
7 Array, Dict, FromValue, Packed, PlainText, Smart, Str, StyleChain, array, cast, dict,
8 elem,
9};
10use crate::layout::Dir;
11use crate::text::{Lang, Region, TextElem};
12
13#[elem(name = "smartquote", PlainText)]
33pub struct SmartQuoteElem {
34 #[default(true)]
36 pub double: bool,
37
38 #[default(true)]
49 pub enabled: bool,
50
51 #[default(false)]
63 pub alternative: bool,
64
65 pub quotes: Smart<SmartQuoteDict>,
90}
91
92impl PlainText for Packed<SmartQuoteElem> {
93 fn plain_text(&self, text: &mut EcoString) {
94 text.push_str(SmartQuotes::fallback(self.double.as_option().unwrap_or(true)));
95 }
96}
97
98#[derive(Debug, Clone)]
100pub struct SmartQuoter {
101 depth: u8,
103 kinds: u32,
106}
107
108impl SmartQuoter {
109 pub fn new() -> Self {
111 Self { depth: 0, kinds: 0 }
112 }
113
114 pub fn quote<'a>(
117 &mut self,
118 before: Option<char>,
119 quotes: &SmartQuotes<'a>,
120 double: bool,
121 ) -> &'a str {
122 let opened = self.top();
123 let before = before.unwrap_or(' ');
124
125 if before.is_numeric() && opened != Some(double) {
128 return if double { "″" } else { "′" };
129 }
130
131 if !double
135 && opened != Some(false)
136 && (before.is_alphabetic() || before == '\u{FFFC}')
137 {
138 return "’";
139 }
140
141 if opened == Some(double)
144 && !before.is_whitespace()
145 && !is_newline(before)
146 && !is_opening_bracket(before)
147 {
148 self.pop();
149 return quotes.close(double);
150 }
151
152 self.push(double);
154 quotes.open(double)
155 }
156
157 fn top(&self) -> Option<bool> {
160 self.depth.checked_sub(1).map(|i| (self.kinds >> i) & 1 == 1)
161 }
162
163 fn push(&mut self, double: bool) {
165 if self.depth < 32 {
166 self.kinds |= (double as u32) << self.depth;
167 self.depth += 1;
168 }
169 }
170
171 fn pop(&mut self) {
173 self.depth -= 1;
174 self.kinds &= (1 << self.depth) - 1;
175 }
176}
177
178impl Default for SmartQuoter {
179 fn default() -> Self {
180 Self::new()
181 }
182}
183
184fn is_opening_bracket(c: char) -> bool {
186 matches!(c, '(' | '{' | '[')
187}
188
189pub struct SmartQuotes<'s> {
191 pub single_open: &'s str,
193 pub single_close: &'s str,
195 pub double_open: &'s str,
197 pub double_close: &'s str,
199}
200
201impl<'s> SmartQuotes<'s> {
202 pub fn get_in(styles: StyleChain<'s>) -> Self {
204 Self::get(
205 styles.get_ref(SmartQuoteElem::quotes),
206 styles.get(TextElem::lang),
207 styles.get(TextElem::region),
208 styles.get(SmartQuoteElem::alternative),
209 )
210 }
211
212 pub fn get(
226 quotes: &'s Smart<SmartQuoteDict>,
227 lang: Lang,
228 region: Option<Region>,
229 alternative: bool,
230 ) -> Self {
231 let region = region.as_ref().map(Region::as_str);
232
233 let default = ("‘", "’", "“", "”");
234 let low_high = ("‚", "‘", "„", "“");
235
236 let (single_open, single_close, double_open, double_close) = match lang {
237 Lang::GERMAN if matches!(region, Some("CH" | "LI")) => match alternative {
238 false => ("‹", "›", "«", "»"),
239 true => low_high,
240 },
241 Lang::FRENCH if matches!(region, Some("CH")) => match alternative {
242 false => ("‹\u{202F}", "\u{202F}›", "«\u{202F}", "\u{202F}»"),
243 true => default,
244 },
245 Lang::CZECH
246 | Lang::DANISH
247 | Lang::GERMAN
248 | Lang::SLOVAK
249 | Lang::SLOVENIAN
250 if alternative =>
251 {
252 ("›", "‹", "»", "«")
253 }
254 Lang::CZECH
255 | Lang::GERMAN
256 | Lang::ESTONIAN
257 | Lang::ICELANDIC
258 | Lang::LITHUANIAN
259 | Lang::LATVIAN
260 | Lang::SLOVAK
261 | Lang::SLOVENIAN => low_high,
262 Lang::DANISH => ("‘", "’", "“", "”"),
263 Lang::FRENCH if alternative => default,
264 Lang::FRENCH => ("“", "”", "«\u{202F}", "\u{202F}»"),
265 Lang::FINNISH | Lang::SWEDISH if alternative => ("’", "’", "»", "»"),
266 Lang::GALICIAN => ("“", "”", "«", "»"),
267 Lang::BOSNIAN | Lang::FINNISH | Lang::SWEDISH => ("’", "’", "”", "”"),
268 Lang::ITALIAN if alternative => default,
269 Lang::LATIN if alternative => ("“", "”", "«\u{202F}", "\u{202F}»"),
270 Lang::ITALIAN | Lang::LATIN => ("“", "”", "«", "»"),
271 Lang::SPANISH if matches!(region, Some("ES") | None) => ("“", "”", "«", "»"),
272 Lang::HUNGARIAN | Lang::POLISH | Lang::ROMANIAN => ("’", "’", "„", "”"),
273 Lang::NORWEGIAN | Lang::NORWEGIAN_BOKMAL | Lang::NORWEGIAN_NYNORSK
274 if alternative =>
275 {
276 low_high
277 }
278 Lang::NORWEGIAN | Lang::NORWEGIAN_BOKMAL | Lang::NORWEGIAN_NYNORSK => {
279 ("’", "’", "«", "»")
280 }
281 Lang::RUSSIAN => ("„", "“", "«", "»"),
282 Lang::UKRAINIAN => ("“", "”", "«", "»"),
283 Lang::GREEK => ("‘", "’", "«", "»"),
284 Lang::HEBREW => ("’", "’", "”", "”"),
285 Lang::CROATIAN => ("‘", "’", "„", "”"),
286 Lang::BULGARIAN => ("’", "’", "„", "“"),
287 Lang::ARABIC if !alternative => ("’", "‘", "«", "»"),
288 _ if lang.dir() == Dir::RTL => ("’", "‘", "”", "“"),
289 _ => default,
290 };
291
292 fn inner_or_default<'s>(
293 quotes: Smart<&'s SmartQuoteDict>,
294 f: impl FnOnce(&'s SmartQuoteDict) -> Smart<&'s SmartQuoteSet>,
295 default: [&'s str; 2],
296 ) -> [&'s str; 2] {
297 match quotes.and_then(f) {
298 Smart::Auto => default,
299 Smart::Custom(SmartQuoteSet { open, close }) => {
300 [open, close].map(|s| s.as_str())
301 }
302 }
303 }
304
305 let quotes = quotes.as_ref();
306 let [single_open, single_close] =
307 inner_or_default(quotes, |q| q.single.as_ref(), [single_open, single_close]);
308 let [double_open, double_close] =
309 inner_or_default(quotes, |q| q.double.as_ref(), [double_open, double_close]);
310
311 Self {
312 single_open,
313 single_close,
314 double_open,
315 double_close,
316 }
317 }
318
319 pub fn open(&self, double: bool) -> &'s str {
321 if double { self.double_open } else { self.single_open }
322 }
323
324 pub fn close(&self, double: bool) -> &'s str {
326 if double { self.double_close } else { self.single_close }
327 }
328
329 pub fn fallback(double: bool) -> &'static str {
331 if double { "\"" } else { "'" }
332 }
333}
334
335#[derive(Debug, Clone, Eq, PartialEq, Hash)]
337pub struct SmartQuoteSet {
338 open: EcoString,
339 close: EcoString,
340}
341
342cast! {
343 SmartQuoteSet,
344 self => array![self.open, self.close].into_value(),
345 value: Array => {
346 let [open, close] = array_to_set(value)?;
347 Self { open, close }
348 },
349 value: Str => {
350 let [open, close] = str_to_set(value.as_str())?;
351 Self { open, close }
352 },
353}
354
355fn str_to_set(value: &str) -> StrResult<[EcoString; 2]> {
356 let mut iter = value.graphemes(true);
357 match (iter.next(), iter.next(), iter.next()) {
358 (Some(open), Some(close), None) => Ok([open.into(), close.into()]),
359 _ => {
360 let count = value.graphemes(true).count();
361 bail!(
362 "expected 2 characters, found {count} character{}",
363 if count > 1 { "s" } else { "" },
364 );
365 }
366 }
367}
368
369fn array_to_set(value: Array) -> HintedStrResult<[EcoString; 2]> {
370 let value = value.as_slice();
371 if value.len() != 2 {
372 bail!(
373 "expected 2 quotes, found {} quote{}",
374 value.len(),
375 if value.len() > 1 { "s" } else { "" },
376 );
377 }
378
379 let open: EcoString = value[0].clone().cast()?;
380 let close: EcoString = value[1].clone().cast()?;
381
382 Ok([open, close])
383}
384
385#[derive(Debug, Clone, Eq, PartialEq, Hash)]
387pub struct SmartQuoteDict {
388 double: Smart<SmartQuoteSet>,
389 single: Smart<SmartQuoteSet>,
390}
391
392cast! {
393 SmartQuoteDict,
394 self => dict! { "double" => self.double, "single" => self.single }.into_value(),
395 mut value: Dict => {
396 let keys = ["double", "single"];
397
398 let double = value
399 .take("double")
400 .ok()
401 .map(FromValue::from_value)
402 .transpose()?
403 .unwrap_or(Smart::Auto);
404 let single = value
405 .take("single")
406 .ok()
407 .map(FromValue::from_value)
408 .transpose()?
409 .unwrap_or(Smart::Auto);
410
411 value.finish(&keys)?;
412
413 Self { single, double }
414 },
415 value: SmartQuoteSet => Self {
416 double: Smart::Custom(value),
417 single: Smart::Auto,
418 },
419}