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::Colon, ScriptClass::Latin, _) => ": ",
69 (Punctuation::Colon, ScriptClass::Cjk, _) => ":",
70 (Punctuation::Semicolon, ScriptClass::Latin, _) => "; ",
71 (Punctuation::Semicolon, ScriptClass::Cjk, _) => ";",
72 (Punctuation::Period, ScriptClass::Latin, _) => ". ",
73 (Punctuation::Period, ScriptClass::Cjk, _) => "。",
74 (Punctuation::Parentheses, ScriptClass::Latin, PunctuationPosition::Prefix) => "(",
75 (Punctuation::Parentheses, ScriptClass::Latin, PunctuationPosition::Suffix) => ")",
76 (Punctuation::Parentheses, ScriptClass::Cjk, PunctuationPosition::Prefix) => "(",
77 (Punctuation::Parentheses, ScriptClass::Cjk, PunctuationPosition::Suffix) => ")",
78 (Punctuation::Brackets, ScriptClass::Latin, PunctuationPosition::Prefix) => "[",
79 (Punctuation::Brackets, ScriptClass::Latin, PunctuationPosition::Suffix) => "]",
80 (Punctuation::Brackets, ScriptClass::Cjk, PunctuationPosition::Prefix) => "【",
81 (Punctuation::Brackets, ScriptClass::Cjk, PunctuationPosition::Suffix) => "】",
82 (Punctuation::Parentheses, ScriptClass::Latin, PunctuationPosition::Separator) => "()",
83 (Punctuation::Parentheses, ScriptClass::Cjk, PunctuationPosition::Separator) => "()",
84 (Punctuation::Brackets, ScriptClass::Latin, PunctuationPosition::Separator) => "[]",
85 (Punctuation::Brackets, ScriptClass::Cjk, PunctuationPosition::Separator) => "【】",
86 (
87 Punctuation::Ampersand
88 | Punctuation::VerticalLine
89 | Punctuation::Slash
90 | Punctuation::Hyphen
91 | Punctuation::Space
92 | Punctuation::None
93 | Punctuation::Custom(_),
94 _,
95 _,
96 ) => return Cow::Borrowed(punctuation.as_default_str()),
97 };
98 Cow::Borrowed(default)
99}
100
101fn pair_mark(pair: &[String; 2], position: PunctuationPosition) -> Cow<'_, str> {
102 match position {
103 PunctuationPosition::Prefix => Cow::Borrowed(pair[0].as_str()),
104 PunctuationPosition::Suffix => Cow::Borrowed(pair[1].as_str()),
105 PunctuationPosition::Separator => Cow::Owned(format!("{}{}", pair[0], pair[1])),
106 }
107}
108
109pub(crate) fn apply_punctuation_affixes<F>(
112 fmt: &F,
113 prefix: Option<(&DelimiterPunctuation, &str)>,
114 mut content: String,
115 suffix: Option<(&DelimiterPunctuation, &str)>,
116) -> String
117where
118 F: OutputFormat<Output = String>,
119{
120 if let Some((punctuation, text)) = prefix {
121 content = if punctuation.is_semantic() {
122 fmt.join(vec![fmt.text(text), content], "")
123 } else {
124 fmt.affix(text, content, "")
125 };
126 }
127 if let Some((punctuation, text)) = suffix {
128 content = if punctuation.is_semantic() {
129 fmt.join(vec![content, fmt.text(text)], "")
130 } else {
131 fmt.affix("", content, text)
132 };
133 }
134 content
135}
136
137#[must_use]
141pub fn unicode_quote_marks(depth: usize) -> (&'static str, &'static str) {
142 if depth.is_multiple_of(2) {
143 ("\u{201C}", "\u{201D}")
144 } else {
145 ("\u{2018}", "\u{2019}")
146 }
147}
148
149#[derive(Debug, Clone, PartialEq, Eq)]
153pub struct QuoteMarks {
154 pub open: String,
156 pub close: String,
158 pub open_inner: String,
160 pub close_inner: String,
162}
163
164impl QuoteMarks {
165 #[must_use]
169 pub fn for_depth(&self, depth: usize) -> (&str, &str) {
170 if depth.is_multiple_of(2) {
171 (&self.open, &self.close)
172 } else {
173 (&self.open_inner, &self.close_inner)
174 }
175 }
176}
177
178impl Default for QuoteMarks {
179 fn default() -> Self {
181 let (open, close) = unicode_quote_marks(0);
182 let (open_inner, close_inner) = unicode_quote_marks(1);
183 Self {
184 open: open.to_string(),
185 close: close.to_string(),
186 open_inner: open_inner.to_string(),
187 close_inner: close_inner.to_string(),
188 }
189 }
190}
191
192impl From<&GrammarOptions> for QuoteMarks {
193 fn from(options: &GrammarOptions) -> Self {
194 Self {
195 open: options.open_quote.clone(),
196 close: options.close_quote.clone(),
197 open_inner: options.open_inner_quote.clone(),
198 close_inner: options.close_inner_quote.clone(),
199 }
200 }
201}
202
203#[derive(Debug, Clone, PartialEq, Eq)]
205pub struct SemanticAttribute {
206 pub name: &'static str,
208 pub value: String,
210}
211
212#[must_use]
220pub(crate) fn realize_wrap<'a>(
221 wrap: &WrapPunctuation,
222 script: ScriptClass,
223 overrides: Option<&'a PunctuationRealization>,
224) -> Option<(Cow<'a, str>, Cow<'a, str>)> {
225 if let Some(pair) = overrides.and_then(|table| match wrap {
226 WrapPunctuation::Parentheses => table.parentheses.as_ref(),
227 WrapPunctuation::Brackets => table.brackets.as_ref(),
228 WrapPunctuation::Quotes => None,
229 }) {
230 return Some((
231 Cow::Borrowed(pair[0].as_str()),
232 Cow::Borrowed(pair[1].as_str()),
233 ));
234 }
235
236 match (wrap, script) {
237 (WrapPunctuation::Parentheses, ScriptClass::Latin) => {
238 Some((Cow::Borrowed("("), Cow::Borrowed(")")))
239 }
240 (WrapPunctuation::Parentheses, ScriptClass::Cjk) => {
241 Some((Cow::Borrowed("("), Cow::Borrowed(")")))
242 }
243 (WrapPunctuation::Brackets, ScriptClass::Latin) => {
244 Some((Cow::Borrowed("["), Cow::Borrowed("]")))
245 }
246 (WrapPunctuation::Brackets, ScriptClass::Cjk) => {
247 Some((Cow::Borrowed("【"), Cow::Borrowed("】")))
248 }
249 (WrapPunctuation::Quotes, _) => None,
250 }
251}
252
253pub trait OutputFormat: Default + Clone {
258 type Output;
263
264 fn text(&self, s: &str) -> Self::Output;
269
270 fn join(&self, items: Vec<Self::Output>, delimiter: &str) -> Self::Output;
272
273 fn finish(&self, output: Self::Output) -> String;
278
279 fn emph(&self, content: Self::Output) -> Self::Output;
281
282 fn strong(&self, content: Self::Output) -> Self::Output;
284
285 fn small_caps(&self, content: Self::Output) -> Self::Output;
287
288 fn superscript(&self, content: Self::Output) -> Self::Output;
290
291 fn quote_marks<'a>(&self, depth: usize, marks: &'a QuoteMarks) -> (&'a str, &'a str) {
298 marks.for_depth(depth)
299 }
300
301 fn quote_with_depth(
303 &self,
304 content: Self::Output,
305 depth: usize,
306 marks: &QuoteMarks,
307 ) -> Self::Output {
308 let (open, close) = self.quote_marks(depth, marks);
309 self.affix(open, content, close)
310 }
311
312 fn quote(&self, content: Self::Output, marks: &QuoteMarks) -> Self::Output {
314 self.quote_with_depth(content, 0, marks)
315 }
316
317 fn affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output;
321
322 fn inner_affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output;
326
327 fn wrap_punctuation(
334 &self,
335 wrap: &WrapPunctuation,
336 content: Self::Output,
337 marks: &QuoteMarks,
338 script: ScriptClass,
339 realization: Option<&PunctuationRealization>,
340 ) -> Self::Output;
341
342 fn semantic(&self, class: &str, content: Self::Output) -> Self::Output;
347
348 fn annotation(&self, content: Self::Output) -> Self::Output;
353
354 fn paragraph(&self, content: Self::Output) -> Self::Output {
359 content
360 }
361
362 fn block_quote(&self, content: Self::Output) -> Self::Output {
364 content
365 }
366
367 fn bullet_list(&self, items: Vec<Self::Output>) -> Self::Output {
369 self.join(items, "\n")
370 }
371
372 fn ordered_list(&self, items: Vec<Self::Output>) -> Self::Output {
374 self.join(items, "\n")
375 }
376
377 fn list_item(&self, content: Self::Output) -> Self::Output {
379 content
380 }
381
382 fn heading(&self, _level: u8, content: Self::Output) -> Self::Output {
384 content
385 }
386
387 fn unnumbered_heading(&self, level: u8, content: Self::Output) -> Self::Output {
394 self.heading(level, content)
395 }
396
397 fn code_block(&self, _lang: Option<&str>, content: Self::Output) -> Self::Output {
401 content
402 }
403
404 fn inline_code(&self, content: Self::Output) -> Self::Output {
406 content
407 }
408
409 fn strikeout(&self, content: Self::Output) -> Self::Output {
411 content
412 }
413
414 fn hard_break(&self) -> Self::Output {
416 self.text(" ")
417 }
418
419 fn semantic_with_attributes(
424 &self,
425 class: &str,
426 content: Self::Output,
427 _attributes: &[SemanticAttribute],
428 ) -> Self::Output {
429 self.semantic(class, content)
430 }
431
432 fn citation(&self, _ids: Vec<String>, content: Self::Output) -> Self::Output {
434 content
435 }
436
437 fn visible_runs(&self, fragment: &str) -> Vec<Range<usize>> {
451 let mut runs = Vec::new();
452 if !fragment.is_empty() {
453 runs.push(0..fragment.len());
454 }
455 runs
456 }
457
458 fn visible_text<'a>(&self, fragment: &'a str) -> Cow<'a, str> {
463 let runs = self.visible_runs(fragment);
464 if runs.len() == 1 && runs.first() == Some(&(0..fragment.len())) {
465 return Cow::Borrowed(fragment);
466 }
467 let mut owned = String::with_capacity(fragment.len());
468 for run in runs {
469 if let Some(slice) = fragment.get(run) {
470 owned.push_str(slice);
471 }
472 }
473 Cow::Owned(owned)
474 }
475
476 fn link(&self, url: &str, content: Self::Output) -> Self::Output;
478
479 fn format_id(&self, id: &str) -> String {
481 id.to_string()
482 }
483
484 fn bibliography(&self, entries: Vec<Self::Output>) -> Self::Output {
488 self.join(entries, "\n\n")
489 }
490
491 fn entry(
495 &self,
496 _id: &str,
497 content: Self::Output,
498 _url: Option<&str>,
499 _metadata: &ProcEntryMetadata,
500 ) -> Self::Output {
501 content
502 }
503}
504
505#[derive(Debug, Clone, Default, PartialEq)]
507pub struct ProcEntryMetadata {
508 pub author: Option<String>,
510 pub year: Option<String>,
512 pub title: Option<String>,
514}
515
516#[cfg(test)]
517#[allow(
518 clippy::unwrap_used,
519 clippy::expect_used,
520 clippy::panic,
521 clippy::indexing_slicing,
522 clippy::todo,
523 clippy::unimplemented,
524 clippy::unreachable,
525 clippy::get_unwrap,
526 reason = "Panicking is acceptable and often desired in tests."
527)]
528mod tests {
529 use super::*;
530
531 #[derive(Default, Clone)]
532 struct DummyFormat;
533
534 impl OutputFormat for DummyFormat {
535 type Output = String;
536 fn text(&self, s: &str) -> Self::Output {
537 s.to_string()
538 }
539 fn join(&self, items: Vec<Self::Output>, delimiter: &str) -> Self::Output {
540 items.join(delimiter)
541 }
542 fn finish(&self, output: Self::Output) -> String {
543 output
544 }
545 fn emph(&self, content: Self::Output) -> Self::Output {
546 format!("emph({content})")
547 }
548 fn strong(&self, content: Self::Output) -> Self::Output {
549 format!("strong({content})")
550 }
551 fn small_caps(&self, content: Self::Output) -> Self::Output {
552 format!("sc({content})")
553 }
554 fn superscript(&self, content: Self::Output) -> Self::Output {
555 format!("sup({content})")
556 }
557 fn affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output {
558 format!("{prefix}{content}{suffix}")
559 }
560 fn inner_affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output {
561 format!("{prefix}{content}{suffix}")
562 }
563 fn wrap_punctuation(
564 &self,
565 _wrap: &WrapPunctuation,
566 content: Self::Output,
567 _marks: &QuoteMarks,
568 _script: ScriptClass,
569 _realization: Option<&PunctuationRealization>,
570 ) -> Self::Output {
571 content
572 }
573 fn semantic(&self, class: &str, content: Self::Output) -> Self::Output {
574 format!("sem[{class}]({content})")
575 }
576 fn annotation(&self, content: Self::Output) -> Self::Output {
577 format!("annot({content})")
578 }
579 fn link(&self, url: &str, content: Self::Output) -> Self::Output {
580 format!("link[{url}]({content})")
581 }
582 }
583
584 #[test]
585 fn test_realize_wrap() {
586 for (wrap, script, expected) in [
587 (
588 WrapPunctuation::Parentheses,
589 ScriptClass::Latin,
590 Some(("(", ")")),
591 ),
592 (
593 WrapPunctuation::Parentheses,
594 ScriptClass::Cjk,
595 Some(("(", ")")),
596 ),
597 (
598 WrapPunctuation::Brackets,
599 ScriptClass::Latin,
600 Some(("[", "]")),
601 ),
602 (
603 WrapPunctuation::Brackets,
604 ScriptClass::Cjk,
605 Some(("【", "】")),
606 ),
607 (WrapPunctuation::Quotes, ScriptClass::Latin, None),
608 (WrapPunctuation::Quotes, ScriptClass::Cjk, None),
609 ] {
610 assert_eq!(
611 realize_wrap(&wrap, script, None)
612 .map(|(open, close)| (open.into_owned(), close.into_owned())),
613 expected.map(|(open, close)| (open.to_string(), close.to_string())),
614 "{wrap:?}/{script:?}"
615 );
616 }
617 }
618
619 #[test]
620 fn paired_punctuation_override_includes_both_marks_as_separator() {
621 let overrides = PunctuationRealization {
622 parentheses: Some(["〔".to_string(), "〕".to_string()]),
623 ..PunctuationRealization::default()
624 };
625
626 assert_eq!(
627 realize_punctuation(
628 &DelimiterPunctuation::Parentheses,
629 ScriptClass::Cjk,
630 Some(&overrides),
631 PunctuationPosition::Separator,
632 ),
633 "〔〕"
634 );
635 }
636
637 #[test]
638 fn test_default_methods() {
639 let fmt = DummyFormat;
640 assert_eq!(
641 fmt.semantic_with_attributes("test", "content".to_string(), &[]),
642 "sem[test](content)"
643 );
644 assert_eq!(
645 fmt.citation(vec!["id1".to_string()], "content".to_string()),
646 "content"
647 );
648 assert_eq!(fmt.format_id("id1"), "id1");
649 assert_eq!(
650 fmt.bibliography(vec!["entry1".to_string(), "entry2".to_string()]),
651 "entry1\n\nentry2"
652 );
653 assert_eq!(
654 fmt.entry(
655 "id1",
656 "content".to_string(),
657 None,
658 &ProcEntryMetadata::default()
659 ),
660 "content"
661 );
662 }
663
664 #[test]
665 fn semantic_affixes_use_each_output_formats_text_escaping() {
666 let punctuation = DelimiterPunctuation::Comma;
667
668 assert_eq!(
669 apply_punctuation_affixes(
670 &crate::render::plain::PlainText,
671 Some((&punctuation, "<&")),
672 "value".to_string(),
673 None,
674 ),
675 "<&value"
676 );
677 assert_eq!(
678 apply_punctuation_affixes(
679 &crate::render::html::Html,
680 Some((&punctuation, "<&")),
681 "value".to_string(),
682 None,
683 ),
684 "<&value"
685 );
686 assert_eq!(
687 apply_punctuation_affixes(
688 &crate::render::latex::Latex,
689 Some((&punctuation, "<&")),
690 "value".to_string(),
691 None,
692 ),
693 "<\\&value"
694 );
695 assert_eq!(
696 apply_punctuation_affixes(
697 &crate::render::typst::Typst,
698 Some((&punctuation, "<&")),
699 "value".to_string(),
700 None,
701 ),
702 "\\<&value"
703 );
704 assert_eq!(
705 apply_punctuation_affixes(
706 &crate::render::markdown::Markdown,
707 Some((&punctuation, "<&")),
708 "value".to_string(),
709 None,
710 ),
711 "\\<\\&value"
712 );
713 assert_eq!(
714 apply_punctuation_affixes(
715 &crate::render::djot::Djot,
716 Some((&punctuation, "<&")),
717 "value".to_string(),
718 None,
719 ),
720 "<&value"
721 );
722 assert_eq!(
723 apply_punctuation_affixes(
724 &crate::render::org::OrgOutputFormat,
725 Some((&punctuation, "<&")),
726 "value".to_string(),
727 None,
728 ),
729 "<&value"
730 );
731 }
732}