1use std::borrow::Cow;
9use std::ops::Range;
10
11use crate::values::ScriptClass;
12use citum_schema::locale::GrammarOptions;
13use citum_schema::options::PunctuationRealization;
14use citum_schema::template::{DelimiterPunctuation, WrapPunctuation};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub(crate) enum PunctuationPosition {
19 Separator,
21 Prefix,
23 Suffix,
25}
26
27#[must_use]
32pub(crate) fn realize_punctuation<'a>(
33 punctuation: &'a DelimiterPunctuation,
34 script: ScriptClass,
35 overrides: Option<&'a PunctuationRealization>,
36 position: PunctuationPosition,
37) -> Cow<'a, str> {
38 use DelimiterPunctuation as Punctuation;
39
40 let override_value = overrides.and_then(|table| match punctuation {
41 Punctuation::Comma => table.comma.as_deref().map(Cow::Borrowed),
42 Punctuation::Colon => table.colon.as_deref().map(Cow::Borrowed),
43 Punctuation::Semicolon => table.semicolon.as_deref().map(Cow::Borrowed),
44 Punctuation::Period => table.period.as_deref().map(Cow::Borrowed),
45 Punctuation::Parentheses => table
46 .parentheses
47 .as_ref()
48 .map(|pair| pair_mark(pair, position)),
49 Punctuation::Brackets => table
50 .brackets
51 .as_ref()
52 .map(|pair| pair_mark(pair, position)),
53 Punctuation::Ampersand
54 | Punctuation::VerticalLine
55 | Punctuation::Slash
56 | Punctuation::Hyphen
57 | Punctuation::Space
58 | Punctuation::None
59 | Punctuation::Custom(_) => None,
60 });
61 if let Some(value) = override_value {
62 return value;
63 }
64
65 let default = match (punctuation, script, position) {
66 (Punctuation::Comma, ScriptClass::Latin, _) => ", ",
67 (Punctuation::Comma, ScriptClass::Cjk, _) => ",",
68 (Punctuation::Comma, ScriptClass::Mixed, _) => ",",
69 (Punctuation::Colon, ScriptClass::Latin, _) => ": ",
70 (Punctuation::Colon, ScriptClass::Cjk, _) => ":",
71 (Punctuation::Colon, ScriptClass::Mixed, _) => ":",
72 (Punctuation::Semicolon, ScriptClass::Latin, _) => "; ",
73 (Punctuation::Semicolon, ScriptClass::Cjk, _) => ";",
74 (Punctuation::Semicolon, ScriptClass::Mixed, _) => ";",
75 (Punctuation::Period, ScriptClass::Latin, _) => ". ",
76 (Punctuation::Period, ScriptClass::Cjk, _) => "。",
77 (Punctuation::Period, ScriptClass::Mixed, _) => ". ",
78 (Punctuation::Parentheses, ScriptClass::Latin, PunctuationPosition::Prefix) => "(",
79 (Punctuation::Parentheses, ScriptClass::Latin, PunctuationPosition::Suffix) => ")",
80 (Punctuation::Parentheses, ScriptClass::Cjk, PunctuationPosition::Prefix) => "(",
81 (Punctuation::Parentheses, ScriptClass::Mixed, PunctuationPosition::Prefix) => "(",
82 (Punctuation::Parentheses, ScriptClass::Cjk, PunctuationPosition::Suffix) => ")",
83 (Punctuation::Parentheses, ScriptClass::Mixed, PunctuationPosition::Suffix) => ")",
84 (Punctuation::Brackets, ScriptClass::Latin, PunctuationPosition::Prefix) => "[",
85 (Punctuation::Brackets, ScriptClass::Latin, PunctuationPosition::Suffix) => "]",
86 (Punctuation::Brackets, ScriptClass::Cjk, PunctuationPosition::Prefix) => "【",
87 (Punctuation::Brackets, ScriptClass::Mixed, PunctuationPosition::Prefix) => "[",
88 (Punctuation::Brackets, ScriptClass::Cjk, PunctuationPosition::Suffix) => "】",
89 (Punctuation::Brackets, ScriptClass::Mixed, PunctuationPosition::Suffix) => "]",
90 (Punctuation::Parentheses, ScriptClass::Latin, PunctuationPosition::Separator) => "()",
91 (Punctuation::Parentheses, ScriptClass::Cjk, PunctuationPosition::Separator) => "()",
92 (Punctuation::Parentheses, ScriptClass::Mixed, PunctuationPosition::Separator) => "()",
93 (Punctuation::Brackets, ScriptClass::Latin, PunctuationPosition::Separator) => "[]",
94 (Punctuation::Brackets, ScriptClass::Cjk, PunctuationPosition::Separator) => "【】",
95 (Punctuation::Brackets, ScriptClass::Mixed, PunctuationPosition::Separator) => "[]",
96 (
97 Punctuation::Ampersand
98 | Punctuation::VerticalLine
99 | Punctuation::Slash
100 | Punctuation::Hyphen
101 | Punctuation::Space
102 | Punctuation::None
103 | Punctuation::Custom(_),
104 _,
105 _,
106 ) => return Cow::Borrowed(punctuation.as_default_str()),
107 };
108 Cow::Borrowed(default)
109}
110
111fn pair_mark(pair: &[String; 2], position: PunctuationPosition) -> Cow<'_, str> {
112 match position {
113 PunctuationPosition::Prefix => Cow::Borrowed(pair[0].as_str()),
114 PunctuationPosition::Suffix => Cow::Borrowed(pair[1].as_str()),
115 PunctuationPosition::Separator => Cow::Owned(format!("{}{}", pair[0], pair[1])),
116 }
117}
118
119pub(crate) fn apply_punctuation_affixes<F>(
122 fmt: &F,
123 prefix: Option<(&DelimiterPunctuation, &str)>,
124 mut content: String,
125 suffix: Option<(&DelimiterPunctuation, &str)>,
126) -> String
127where
128 F: OutputFormat<Output = String>,
129{
130 if let Some((punctuation, text)) = prefix {
131 content = if punctuation.is_semantic() {
132 fmt.join(vec![fmt.text(text), content], "")
133 } else {
134 fmt.affix(text, content, "")
135 };
136 }
137 if let Some((punctuation, text)) = suffix {
138 content = if punctuation.is_semantic() {
139 fmt.join(vec![content, fmt.text(text)], "")
140 } else {
141 fmt.affix("", content, text)
142 };
143 }
144 content
145}
146
147#[must_use]
151pub fn unicode_quote_marks(depth: usize) -> (&'static str, &'static str) {
152 if depth.is_multiple_of(2) {
153 ("\u{201C}", "\u{201D}")
154 } else {
155 ("\u{2018}", "\u{2019}")
156 }
157}
158
159#[derive(Debug, Clone, PartialEq, Eq)]
163pub struct QuoteMarks {
164 pub open: String,
166 pub close: String,
168 pub open_inner: String,
170 pub close_inner: String,
172 pub punctuation_realization: Option<citum_schema::options::PunctuationRealization>,
174}
175
176impl QuoteMarks {
177 #[must_use]
181 pub fn for_depth(&self, depth: usize) -> (&str, &str) {
182 if depth.is_multiple_of(2) {
183 (&self.open, &self.close)
184 } else {
185 (&self.open_inner, &self.close_inner)
186 }
187 }
188}
189
190impl Default for QuoteMarks {
191 fn default() -> Self {
193 let (open, close) = unicode_quote_marks(0);
194 let (open_inner, close_inner) = unicode_quote_marks(1);
195 Self {
196 open: open.to_string(),
197 close: close.to_string(),
198 open_inner: open_inner.to_string(),
199 close_inner: close_inner.to_string(),
200 punctuation_realization: None,
201 }
202 }
203}
204
205impl From<&GrammarOptions> for QuoteMarks {
206 fn from(options: &GrammarOptions) -> Self {
207 Self {
208 open: options.open_quote.clone(),
209 close: options.close_quote.clone(),
210 open_inner: options.open_inner_quote.clone(),
211 close_inner: options.close_inner_quote.clone(),
212 punctuation_realization: None,
213 }
214 }
215}
216
217impl From<&citum_schema::locale::Locale> for QuoteMarks {
218 fn from(locale: &citum_schema::locale::Locale) -> Self {
219 Self {
220 open: locale.grammar_options.open_quote.clone(),
221 close: locale.grammar_options.close_quote.clone(),
222 open_inner: locale.grammar_options.open_inner_quote.clone(),
223 close_inner: locale.grammar_options.close_inner_quote.clone(),
224 punctuation_realization: locale.punctuation_realization.clone(),
225 }
226 }
227}
228
229#[derive(Debug, Clone, PartialEq, Eq)]
231pub struct SemanticAttribute {
232 pub name: &'static str,
234 pub value: String,
236}
237
238#[must_use]
246pub(crate) fn realize_wrap<'a>(
247 wrap: &WrapPunctuation,
248 script: ScriptClass,
249 overrides: Option<&'a PunctuationRealization>,
250) -> Option<(Cow<'a, str>, Cow<'a, str>)> {
251 if let Some(pair) = overrides.and_then(|table| match wrap {
252 WrapPunctuation::Parentheses => table.parentheses.as_ref(),
253 WrapPunctuation::Brackets => table.brackets.as_ref(),
254 WrapPunctuation::Quotes => None,
255 }) {
256 return Some((
257 Cow::Borrowed(pair[0].as_str()),
258 Cow::Borrowed(pair[1].as_str()),
259 ));
260 }
261
262 match (wrap, script) {
263 (WrapPunctuation::Parentheses, ScriptClass::Latin) => {
264 Some((Cow::Borrowed("("), Cow::Borrowed(")")))
265 }
266 (WrapPunctuation::Parentheses, ScriptClass::Cjk) => {
267 Some((Cow::Borrowed("("), Cow::Borrowed(")")))
268 }
269 (WrapPunctuation::Parentheses, ScriptClass::Mixed) => {
270 Some((Cow::Borrowed("("), Cow::Borrowed(")")))
271 }
272 (WrapPunctuation::Brackets, ScriptClass::Latin) => {
273 Some((Cow::Borrowed("["), Cow::Borrowed("]")))
274 }
275 (WrapPunctuation::Brackets, ScriptClass::Cjk) => {
276 Some((Cow::Borrowed("【"), Cow::Borrowed("】")))
277 }
278 (WrapPunctuation::Brackets, ScriptClass::Mixed) => {
279 Some((Cow::Borrowed("["), Cow::Borrowed("]")))
280 }
281 (WrapPunctuation::Quotes, _) => None,
282 }
283}
284
285pub trait OutputFormat: Default + Clone {
290 type Output;
295
296 fn text(&self, s: &str) -> Self::Output;
301
302 fn join(&self, items: Vec<Self::Output>, delimiter: &str) -> Self::Output;
304
305 fn finish(&self, output: Self::Output) -> String;
310
311 fn emph(&self, content: Self::Output) -> Self::Output;
313
314 fn strong(&self, content: Self::Output) -> Self::Output;
316
317 fn small_caps(&self, content: Self::Output) -> Self::Output;
319
320 fn superscript(&self, content: Self::Output) -> Self::Output;
322
323 fn quote_marks<'a>(&self, depth: usize, marks: &'a QuoteMarks) -> (&'a str, &'a str) {
330 marks.for_depth(depth)
331 }
332
333 fn quote_with_depth(
335 &self,
336 content: Self::Output,
337 depth: usize,
338 marks: &QuoteMarks,
339 ) -> Self::Output {
340 let (open, close) = self.quote_marks(depth, marks);
341 self.affix(open, content, close)
342 }
343
344 fn quote(&self, content: Self::Output, marks: &QuoteMarks) -> Self::Output {
346 self.quote_with_depth(content, 0, marks)
347 }
348
349 fn affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output;
353
354 fn inner_affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output;
358
359 fn wrap_punctuation(
366 &self,
367 wrap: &WrapPunctuation,
368 content: Self::Output,
369 marks: &QuoteMarks,
370 script: ScriptClass,
371 realization: Option<&PunctuationRealization>,
372 ) -> Self::Output;
373
374 fn semantic(&self, class: &str, content: Self::Output) -> Self::Output;
379
380 fn annotation(&self, content: Self::Output) -> Self::Output;
385
386 fn paragraph(&self, content: Self::Output) -> Self::Output {
391 content
392 }
393
394 fn block_quote(&self, content: Self::Output) -> Self::Output {
396 content
397 }
398
399 fn bullet_list(&self, items: Vec<Self::Output>) -> Self::Output {
401 self.join(items, "\n")
402 }
403
404 fn ordered_list(&self, items: Vec<Self::Output>) -> Self::Output {
406 self.join(items, "\n")
407 }
408
409 fn list_item(&self, content: Self::Output) -> Self::Output {
411 content
412 }
413
414 fn heading(&self, _level: u8, content: Self::Output) -> Self::Output {
416 content
417 }
418
419 fn unnumbered_heading(&self, level: u8, content: Self::Output) -> Self::Output {
426 self.heading(level, content)
427 }
428
429 fn code_block(&self, _lang: Option<&str>, content: Self::Output) -> Self::Output {
433 content
434 }
435
436 fn inline_code(&self, content: Self::Output) -> Self::Output {
438 content
439 }
440
441 fn strikeout(&self, content: Self::Output) -> Self::Output {
443 content
444 }
445
446 fn hard_break(&self) -> Self::Output {
448 self.text(" ")
449 }
450
451 fn semantic_with_attributes(
456 &self,
457 class: &str,
458 content: Self::Output,
459 _attributes: &[SemanticAttribute],
460 ) -> Self::Output {
461 self.semantic(class, content)
462 }
463
464 fn citation(&self, _ids: Vec<String>, content: Self::Output) -> Self::Output {
466 content
467 }
468
469 fn visible_runs(&self, fragment: &str) -> Vec<Range<usize>> {
483 let mut runs = Vec::new();
484 if !fragment.is_empty() {
485 runs.push(0..fragment.len());
486 }
487 runs
488 }
489
490 fn visible_text<'a>(&self, fragment: &'a str) -> Cow<'a, str> {
495 let runs = self.visible_runs(fragment);
496 if runs.len() == 1 && runs.first() == Some(&(0..fragment.len())) {
497 return Cow::Borrowed(fragment);
498 }
499 let mut owned = String::with_capacity(fragment.len());
500 for run in runs {
501 if let Some(slice) = fragment.get(run) {
502 owned.push_str(slice);
503 }
504 }
505 Cow::Owned(owned)
506 }
507
508 fn link(&self, url: &str, content: Self::Output) -> Self::Output;
510
511 fn format_id(&self, id: &str) -> String {
513 id.to_string()
514 }
515
516 fn bibliography(&self, entries: Vec<Self::Output>) -> Self::Output {
520 self.join(entries, "\n\n")
521 }
522
523 fn entry(
527 &self,
528 _id: &str,
529 content: Self::Output,
530 _url: Option<&str>,
531 _metadata: &ProcEntryMetadata,
532 ) -> Self::Output {
533 content
534 }
535}
536
537#[derive(Debug, Clone, Default, PartialEq)]
539pub struct ProcEntryMetadata {
540 pub author: Option<String>,
542 pub year: Option<String>,
544 pub title: Option<String>,
546}
547
548#[cfg(test)]
549#[allow(
550 clippy::unwrap_used,
551 clippy::expect_used,
552 clippy::panic,
553 clippy::indexing_slicing,
554 clippy::todo,
555 clippy::unimplemented,
556 clippy::unreachable,
557 clippy::get_unwrap,
558 reason = "Panicking is acceptable and often desired in tests."
559)]
560mod tests {
561 use super::*;
562
563 #[derive(Default, Clone)]
564 struct DummyFormat;
565
566 impl OutputFormat for DummyFormat {
567 type Output = String;
568 fn text(&self, s: &str) -> Self::Output {
569 s.to_string()
570 }
571 fn join(&self, items: Vec<Self::Output>, delimiter: &str) -> Self::Output {
572 items.join(delimiter)
573 }
574 fn finish(&self, output: Self::Output) -> String {
575 output
576 }
577 fn emph(&self, content: Self::Output) -> Self::Output {
578 format!("emph({content})")
579 }
580 fn strong(&self, content: Self::Output) -> Self::Output {
581 format!("strong({content})")
582 }
583 fn small_caps(&self, content: Self::Output) -> Self::Output {
584 format!("sc({content})")
585 }
586 fn superscript(&self, content: Self::Output) -> Self::Output {
587 format!("sup({content})")
588 }
589 fn affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output {
590 format!("{prefix}{content}{suffix}")
591 }
592 fn inner_affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output {
593 format!("{prefix}{content}{suffix}")
594 }
595 fn wrap_punctuation(
596 &self,
597 _wrap: &WrapPunctuation,
598 content: Self::Output,
599 _marks: &QuoteMarks,
600 _script: ScriptClass,
601 _realization: Option<&PunctuationRealization>,
602 ) -> Self::Output {
603 content
604 }
605 fn semantic(&self, class: &str, content: Self::Output) -> Self::Output {
606 format!("sem[{class}]({content})")
607 }
608 fn annotation(&self, content: Self::Output) -> Self::Output {
609 format!("annot({content})")
610 }
611 fn link(&self, url: &str, content: Self::Output) -> Self::Output {
612 format!("link[{url}]({content})")
613 }
614 }
615
616 #[test]
617 fn test_realize_wrap() {
618 for (wrap, script, expected) in [
619 (
620 WrapPunctuation::Parentheses,
621 ScriptClass::Latin,
622 Some(("(", ")")),
623 ),
624 (
625 WrapPunctuation::Parentheses,
626 ScriptClass::Cjk,
627 Some(("(", ")")),
628 ),
629 (
630 WrapPunctuation::Brackets,
631 ScriptClass::Latin,
632 Some(("[", "]")),
633 ),
634 (
635 WrapPunctuation::Brackets,
636 ScriptClass::Cjk,
637 Some(("【", "】")),
638 ),
639 (WrapPunctuation::Quotes, ScriptClass::Latin, None),
640 (WrapPunctuation::Quotes, ScriptClass::Cjk, None),
641 ] {
642 assert_eq!(
643 realize_wrap(&wrap, script, None)
644 .map(|(open, close)| (open.into_owned(), close.into_owned())),
645 expected.map(|(open, close)| (open.to_string(), close.to_string())),
646 "{wrap:?}/{script:?}"
647 );
648 }
649 }
650
651 #[test]
652 fn paired_punctuation_override_includes_both_marks_as_separator() {
653 let overrides = PunctuationRealization {
654 parentheses: Some(["〔".to_string(), "〕".to_string()]),
655 ..PunctuationRealization::default()
656 };
657
658 assert_eq!(
659 realize_punctuation(
660 &DelimiterPunctuation::Parentheses,
661 ScriptClass::Cjk,
662 Some(&overrides),
663 PunctuationPosition::Separator,
664 ),
665 "〔〕"
666 );
667 }
668
669 #[test]
670 fn test_default_methods() {
671 let fmt = DummyFormat;
672 assert_eq!(
673 fmt.semantic_with_attributes("test", "content".to_string(), &[]),
674 "sem[test](content)"
675 );
676 assert_eq!(
677 fmt.citation(vec!["id1".to_string()], "content".to_string()),
678 "content"
679 );
680 assert_eq!(fmt.format_id("id1"), "id1");
681 assert_eq!(
682 fmt.bibliography(vec!["entry1".to_string(), "entry2".to_string()]),
683 "entry1\n\nentry2"
684 );
685 assert_eq!(
686 fmt.entry(
687 "id1",
688 "content".to_string(),
689 None,
690 &ProcEntryMetadata::default()
691 ),
692 "content"
693 );
694 }
695
696 #[test]
697 fn semantic_affixes_use_each_output_formats_text_escaping() {
698 let punctuation = DelimiterPunctuation::Comma;
699
700 assert_eq!(
701 apply_punctuation_affixes(
702 &crate::render::plain::PlainText,
703 Some((&punctuation, "<&")),
704 "value".to_string(),
705 None,
706 ),
707 "<&value"
708 );
709 assert_eq!(
710 apply_punctuation_affixes(
711 &crate::render::html::Html,
712 Some((&punctuation, "<&")),
713 "value".to_string(),
714 None,
715 ),
716 "<&value"
717 );
718 assert_eq!(
719 apply_punctuation_affixes(
720 &crate::render::latex::Latex,
721 Some((&punctuation, "<&")),
722 "value".to_string(),
723 None,
724 ),
725 "<\\&value"
726 );
727 assert_eq!(
728 apply_punctuation_affixes(
729 &crate::render::typst::Typst,
730 Some((&punctuation, "<&")),
731 "value".to_string(),
732 None,
733 ),
734 "\\<&value"
735 );
736 assert_eq!(
737 apply_punctuation_affixes(
738 &crate::render::markdown::Markdown,
739 Some((&punctuation, "<&")),
740 "value".to_string(),
741 None,
742 ),
743 "\\<\\&value"
744 );
745 assert_eq!(
746 apply_punctuation_affixes(
747 &crate::render::djot::Djot,
748 Some((&punctuation, "<&")),
749 "value".to_string(),
750 None,
751 ),
752 "<&value"
753 );
754 assert_eq!(
755 apply_punctuation_affixes(
756 &crate::render::org::OrgOutputFormat,
757 Some((&punctuation, "<&")),
758 "value".to_string(),
759 None,
760 ),
761 "<&value"
762 );
763 }
764}