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>,
88}
89
90impl PlainText for Packed<SmartQuoteElem> {
91 fn plain_text(&self, text: &mut EcoString) {
92 text.push_str(SmartQuotes::fallback(self.double.as_option().unwrap_or(true)));
93 }
94}
95
96#[derive(Debug, Clone)]
98pub struct SmartQuoter {
99 depth: u8,
101 kinds: u32,
104}
105
106impl SmartQuoter {
107 pub fn new() -> Self {
109 Self { depth: 0, kinds: 0 }
110 }
111
112 pub fn quote<'a>(
115 &mut self,
116 before: Option<char>,
117 quotes: &SmartQuotes<'a>,
118 double: bool,
119 ) -> &'a str {
120 let opened = self.top();
121 let before = before.unwrap_or(' ');
122
123 if before.is_numeric() && opened != Some(double) {
126 return if double { "″" } else { "′" };
127 }
128
129 if !double
133 && opened != Some(false)
134 && (before.is_alphabetic() || before == '\u{FFFC}')
135 {
136 return "’";
137 }
138
139 if opened == Some(double)
142 && !before.is_whitespace()
143 && !is_newline(before)
144 && !is_opening_bracket(before)
145 {
146 self.pop();
147 return quotes.close(double);
148 }
149
150 self.push(double);
152 quotes.open(double)
153 }
154
155 fn top(&self) -> Option<bool> {
158 self.depth.checked_sub(1).map(|i| (self.kinds >> i) & 1 == 1)
159 }
160
161 fn push(&mut self, double: bool) {
163 if self.depth < 32 {
164 self.kinds |= (double as u32) << self.depth;
165 self.depth += 1;
166 }
167 }
168
169 fn pop(&mut self) {
171 self.depth -= 1;
172 self.kinds &= (1 << self.depth) - 1;
173 }
174}
175
176impl Default for SmartQuoter {
177 fn default() -> Self {
178 Self::new()
179 }
180}
181
182fn is_opening_bracket(c: char) -> bool {
184 matches!(c, '(' | '{' | '[')
185}
186
187pub struct SmartQuotes<'s> {
189 pub single_open: &'s str,
191 pub single_close: &'s str,
193 pub double_open: &'s str,
195 pub double_close: &'s str,
197}
198
199impl<'s> SmartQuotes<'s> {
200 pub fn get_in(styles: StyleChain<'s>) -> Self {
202 Self::get(
203 styles.get_ref(SmartQuoteElem::quotes),
204 styles.get(TextElem::lang),
205 styles.get(TextElem::region),
206 styles.get(SmartQuoteElem::alternative),
207 )
208 }
209
210 pub fn get(
224 quotes: &'s Smart<SmartQuoteDict>,
225 lang: Lang,
226 region: Option<Region>,
227 alternative: bool,
228 ) -> Self {
229 let region = region.as_ref().map(Region::as_str);
230
231 let default = ("‘", "’", "“", "”");
232 let low_high = ("‚", "‘", "„", "“");
233
234 let (single_open, single_close, double_open, double_close) = match lang.as_str() {
235 "de" if matches!(region, Some("CH" | "LI")) => match alternative {
236 false => ("‹", "›", "«", "»"),
237 true => low_high,
238 },
239 "fr" if matches!(region, Some("CH")) => match alternative {
240 false => ("‹\u{202F}", "\u{202F}›", "«\u{202F}", "\u{202F}»"),
241 true => default,
242 },
243 "cs" | "da" | "de" | "sk" | "sl" if alternative => ("›", "‹", "»", "«"),
244 "cs" | "de" | "et" | "is" | "lt" | "lv" | "sk" | "sl" => low_high,
245 "da" => ("‘", "’", "“", "”"),
246 "fr" if alternative => default,
247 "fr" => ("“", "”", "«\u{202F}", "\u{202F}»"),
248 "fi" | "sv" if alternative => ("’", "’", "»", "»"),
249 "bs" | "fi" | "sv" => ("’", "’", "”", "”"),
250 "it" if alternative => default,
251 "la" if alternative => ("“", "”", "«\u{202F}", "\u{202F}»"),
252 "it" | "la" => ("“", "”", "«", "»"),
253 "es" if matches!(region, Some("ES") | None) => ("“", "”", "«", "»"),
254 "hu" | "pl" | "ro" => ("’", "’", "„", "”"),
255 "no" | "nb" | "nn" if alternative => low_high,
256 "no" | "nb" | "nn" => ("’", "’", "«", "»"),
257 "ru" => ("„", "“", "«", "»"),
258 "uk" => ("“", "”", "«", "»"),
259 "el" => ("‘", "’", "«", "»"),
260 "he" => ("’", "’", "”", "”"),
261 "hr" => ("‘", "’", "„", "”"),
262 "bg" => ("’", "’", "„", "“"),
263 "ar" if !alternative => ("’", "‘", "«", "»"),
264 _ if lang.dir() == Dir::RTL => ("’", "‘", "”", "“"),
265 _ => default,
266 };
267
268 fn inner_or_default<'s>(
269 quotes: Smart<&'s SmartQuoteDict>,
270 f: impl FnOnce(&'s SmartQuoteDict) -> Smart<&'s SmartQuoteSet>,
271 default: [&'s str; 2],
272 ) -> [&'s str; 2] {
273 match quotes.and_then(f) {
274 Smart::Auto => default,
275 Smart::Custom(SmartQuoteSet { open, close }) => {
276 [open, close].map(|s| s.as_str())
277 }
278 }
279 }
280
281 let quotes = quotes.as_ref();
282 let [single_open, single_close] =
283 inner_or_default(quotes, |q| q.single.as_ref(), [single_open, single_close]);
284 let [double_open, double_close] =
285 inner_or_default(quotes, |q| q.double.as_ref(), [double_open, double_close]);
286
287 Self {
288 single_open,
289 single_close,
290 double_open,
291 double_close,
292 }
293 }
294
295 pub fn open(&self, double: bool) -> &'s str {
297 if double { self.double_open } else { self.single_open }
298 }
299
300 pub fn close(&self, double: bool) -> &'s str {
302 if double { self.double_close } else { self.single_close }
303 }
304
305 pub fn fallback(double: bool) -> &'static str {
307 if double { "\"" } else { "'" }
308 }
309}
310
311#[derive(Debug, Clone, Eq, PartialEq, Hash)]
313pub struct SmartQuoteSet {
314 open: EcoString,
315 close: EcoString,
316}
317
318cast! {
319 SmartQuoteSet,
320 self => array![self.open, self.close].into_value(),
321 value: Array => {
322 let [open, close] = array_to_set(value)?;
323 Self { open, close }
324 },
325 value: Str => {
326 let [open, close] = str_to_set(value.as_str())?;
327 Self { open, close }
328 },
329}
330
331fn str_to_set(value: &str) -> StrResult<[EcoString; 2]> {
332 let mut iter = value.graphemes(true);
333 match (iter.next(), iter.next(), iter.next()) {
334 (Some(open), Some(close), None) => Ok([open.into(), close.into()]),
335 _ => {
336 let count = value.graphemes(true).count();
337 bail!(
338 "expected 2 characters, found {count} character{}",
339 if count > 1 { "s" } else { "" }
340 );
341 }
342 }
343}
344
345fn array_to_set(value: Array) -> HintedStrResult<[EcoString; 2]> {
346 let value = value.as_slice();
347 if value.len() != 2 {
348 bail!(
349 "expected 2 quotes, found {} quote{}",
350 value.len(),
351 if value.len() > 1 { "s" } else { "" }
352 );
353 }
354
355 let open: EcoString = value[0].clone().cast()?;
356 let close: EcoString = value[1].clone().cast()?;
357
358 Ok([open, close])
359}
360
361#[derive(Debug, Clone, Eq, PartialEq, Hash)]
363pub struct SmartQuoteDict {
364 double: Smart<SmartQuoteSet>,
365 single: Smart<SmartQuoteSet>,
366}
367
368cast! {
369 SmartQuoteDict,
370 self => dict! { "double" => self.double, "single" => self.single }.into_value(),
371 mut value: Dict => {
372 let keys = ["double", "single"];
373
374 let double = value
375 .take("double")
376 .ok()
377 .map(FromValue::from_value)
378 .transpose()?
379 .unwrap_or(Smart::Auto);
380 let single = value
381 .take("single")
382 .ok()
383 .map(FromValue::from_value)
384 .transpose()?
385 .unwrap_or(Smart::Auto);
386
387 value.finish(&keys)?;
388
389 Self { single, double }
390 },
391 value: SmartQuoteSet => Self {
392 double: Smart::Custom(value),
393 single: Smart::Auto,
394 },
395}